[Vue/Nuxt] Vue Test Utils の コードスニペット集

サンプルコードのテストランナーはJestです。

jest.config.js

ルートにおいとけばとりあえず動くと思うコンフィグ。
testEnviromentはjest-environment-jsdom-global
アセットのスタブにjest-transform-stubを利用する。

const path = require('path');
module.exports = {
  verbose: false, // 実行中に各テストを報告するかどうか
  testPathIgnorePatterns: [
    '/node_modules/'
  ],
  moduleFileExtensions: ['json', 'js', 'vue'],
  transform: {
    '^.+\\.js$': 'babel-jest',
    '.*\\.(vue)$': 'vue-jest',
    '.+\\.(css|scss|png|jpg|svg)$': 'jest-transform-stub'
  },
  transformIgnorePatterns: ['/node_modules/(?!(@storybook/.*\\.vue$))'],
  moduleNameMapper: {
    '^@/(.*)$': path.join(__dirname, '/', '$1'),
    '^(.+)/@/(.+)$': path.join(__dirname, '/', '$2'),
    '^~/(.*)$': path.join(__dirname, '/', '$1')
  },
  testEnvironment: 'jest-environment-jsdom-global',
  testURL: 'https://example.com'
};

基本

コンポーネントが表示されているか

<template>
<div class="sample">Sample</div>
</template>
<script>
export default {
  name: 'Sample'
}
</script>
import { mount } from '@vue/test-utils';
import Sample from '@/components/Sample.vue';

describe('Sample.vue', () => {
  let wrapper;

  afterEach(() => {
    wrapper.destroy();
  });

  it('コンポーネントが表示される', () => {
    wrapper = mount(Sample);
   
    // wrapperが存在する
    expect(wrapper.exists()).toBe(true);
    
    // wrapperのルート要素に.sampleクラスがついている
    expect(wrapper.classes()).toContain('sample');
    
    // wrapperのルート要素はdivである
    expect(wrapper.is('div')).toBe(true);
    
    // wrapperのテキストは「Sample」である
    expect(wrapper.text()).toBe('Sample');
    
    // wrapperのマークアップ検証
    expect(wrapper.html()).toBe('<div class="sample">Sample</div>');
  });
});

mountshallowMountでTestUtilが作成したWrapperオブジェクトが返される。
テストをいくつも続けて書く場合はafterEachでdestroyするが、テスト対象が関数型コンポーネントの場合はインスタンスが作成されないのでdestoryを実行しているとエラーになる。

import { mount } from '@vue/test-utils';
import Sample from '@/components/Sample.vue';

describe('Sample.vue', () => {
  it('(関数型の)コンポーネントが表示される', () => {
    const wrapper = mount(Sample);
    expect(wrapper.classes()).toContain('sample');
    expect(wrapper.is('div')).toBe(true);
    expect(wrapper.text()).toBe('Sample');
  });
});

※以降のテストコードはwrapper.destroyを省略している。

コンポーネントが表示されてない

isVisible()は、対象のWrapperでv-showを使っていればマッチする。

expect(wrapper.isVisible()).toBeTruthy();

Wrapperのルート要素でv-ifを使っている場合だと、isVisible()はtrueになるので、html()undefinedになるかをチェックする

expect(wrapper.html()).toBe(undefined);

コンポーネントプロパティ

<template>
<div class="sample">{{ number }}</div>
</template>
<script>
export default {
  name: 'Sample',
  props: {
    number: {
      type: Number,
      default: 0
    }
  }
}
</script>
import { mount } from '@vue/test-utils';
import Sample from '@/components/Sample.vue';

describe('Sample.vue', () => {

  it('mountオプションでプロパティを渡す', () => {
    const wrapper = mount(Sample, {
       propsData: {
         number: 100
       }
    });

    // インスタンスプロパティへのアクセス
    expect(wrapper.vm.props.number).toBe(100);

    // 渡したプロパティの出力検証
    expect(wrapper.text()).toBe('100');
  });
});

プロパティはマウントオプションのpropsDataで渡せる。
関数型コンポーネント相手の場合は context: { props: {} } でもおk。

テストの途中でプロパティ値を変更する場合はsetPropsメソッドを利用する。

it('テストの途中でプロパティ値を変更する', () => {
    wrapper = mount(Sample);
    // デフォルト
    expect(wrapper.text()).toBe('0');
    // プロパティの変更
    wrapper.setProps({ number: 100 });
    // 変更後
    expect(wrapper.text()).toBe('100');
  });

子コンポーネントの存在

<template>
<div class="sample">
  <SampleChild />
</div>
</template>
<script>
import SampleChild from '@/components/SampleChild';

export default {
  name: 'Sample',
  components: { SampleChild }
}
</script>
<template>
<div class="child">This is Child</div>
</template>
<script>
export default {
  name: 'SampleChild'
}
</script>
import { shallowMount } from '@vue/test-utils';
import Sample from '@/components/Sample.vue';
import SampleChild from '@/components/SampleChild.vue';

describe('Sample.vue', () => {
  it('子コンポーネントが表示されている', () => {
    const wrapper = shallowMount(Sample);
    expect(wrapper.find('.child').is(SampleChild)).toBe(true);
    expect(wrapper.find(SampleChild).text()).toBe('This is Child');
  });
});

wrapper.findやwrapper.isはVueコンポーネントを渡すことができる。
wrapper.html()でマークアップ検証する場合は子コンポーネントがスタブされるshallowMountではなくmountを使う。

イベントハンドラ

<template>
<div class="sample">
 <button type="button" @click="handleClick">{{ count }}</button>
</div>
</template>
<script>
export default {
  name: 'Sample',
  data() {
    return {
      count: 0
    }
  },
  methods: {
    handleClick() {
      this.count += 1;
    }
  }
}
</script>
import { mount } from '@vue/test-utils';
import Sample from '@/components/Sample.vue';

describe('Sample.vue', () => {
  it('クリック時にイベントハンドラが実行される', () => {
    const wrapper = mount(Sample);
    // デフォルト
    expect(wrapper.text()).toBe('0');
    // clickイベント発火
    wrapper.find('button').trigger('click');
    // イベント後
    expect(wrapper.text()).toBe('1');
   
  });
});

wrapper.trigger('イベント名')でイベントを発火できる

ラジオボタンやチェックボックス

<template>
<fieldset class="sample">
  <label><input type="radio" v-model="selected" value="one" name="sample" /> One</label>
  <label><input type="radio" v-model="selected" value="two" name="sample" /> Two</label>
  <label><input type="radio" v-model="selected" value="three" name="sample" /> Three</label>
</fieldset>
</template>
<script>
export default {
  name: 'Sample',
  data() {
    return {
      selected: 'one'
    }
  }
}
</script>
import { mount } from '@vue/test-utils';
import Sample from '@/components/Sample.vue';

describe('Sample.vue', () => {
  it('ラジオボタンの検証', () => {
    const wrapper = mount(Sample);

    const radios = wrapper.findAll('input');
    expect(radios.length).toBe(3);
    expect(radios.is('input[type=radio]')).toBe(true);
    expect(radios.at(0).element.checked).toBe(true);
    expect(radios.at(1).element.checked).toBe(false);
    expect(radios.at(2).element.checked).toBe(false);
   
  });
});

ラジオボタンやチェックボックスがアクティブかどうかの検証は wrapper.element.checked を利用する。

$emitしたカスタムイベント

<template>
<div class="sample">Sample</div>
</template>
<script>
export default {
  name: 'Sample',
  mounted() {
    this.$emit('sampleEvent', 'ok');
  }
}
</script>
import { mount } from '@vue/test-utils';
import Sample from '@/components/Sample.vue';

describe('Sample.vue', () => {
  it('$emitの検証', () => {
    const wrapper = mount(Sample);

    // イベントが発行されたかどうか
    expect(wrapper.emitted('sampleEvent')).toBeDefined()
    // イベントの実行回数
    expect(wrapper.emitted('sampleEvent').length).toBe(1)
    // イベントのペイロードを検証
    expect(wrapper.emitted('sampleEvent')[0][0]).toEqual('ok')
  });
});

発行順序を検証する場合はemittedByOrderを利用する。

watch内の処理

<template>
<div class="sample">{{ text }}</div>
</template>
<script>
export default {
  name: 'Sample',
  data() {
    const text = 'Sample';
    return {
       text,
       length: text.length
    }
  },
  watch {
    text (after, before) {
       this.length = after.length;
    }
  }
}
</script>
import { mount } from '@vue/test-utils';
import Sample from '@/components/Sample.vue';

describe('Sample.vue', () => {
  it('watchの検証', () => {
    const wrapper = mount(Sample);
    
    expect(wrapper.vm.text).toBe('Sample');
    expect(wrapper.vm.length).toBe(6);
    
    wrapper.setData({ text: 'Test' });

    wrapper.vm.$nextTick(() => {
      expect(wrapper.vm.text).toBe('Test');
      expect(wrapper.vm.length).toBe(4);
      done();
    });
  });
});

watchは$nextTickを使わないと変化を検知できない。
createdやmountedで何か実行してv-ifやv-showを切り替えるコンポーネントにも、$nextTickが必要。

nuxt-link(router-link)を利用するコンポーネント

nuxt-link は Vue Router の router-link を拡張したNuxt.jsのAPIですが、

<template>
<nuxt-link class="sample" to="/about">About</nuxt-link>
</template>
<script>
export default {
  name: 'Sample'
}
</script>

特に何もしてないと登録してないコンポーネントの扱いをされるので、
検証ではrouter-linkをスタブするRouterLinkStubを利用する。

import { mount, RouterLinkStub } from '@vue/test-utils';
import Sample from '@/components/Sample.vue';

describe('Sample.vue', () => {

  it('nuxt-linkがあるコンポーネントの検証', () => {
    const wrapper = mount(Sample, {
      stubs: {
        NuxtLink: RouterLinkStub,
        RouterLink: RouterLinkStub // router-linkの場合はこっち
      }
    });

    expect(wrapper.find(RouterLinkStub).props().to).toBe('/about')
   
  });
});

Vuexを利用するコンポーネントの検証

$storeプロパティを参照しているコンポーネント

<template>
<div class="sample">{{ $store.state.text }}</div>
</template>
<script>
export default {
  name: 'Sample'
}
</script>
import { mount } from '@vue/test-utils';
import Sample from '@/components/Sample.vue';

describe('Sample.vue', () => {
  it('state.textの文字が表示される', () => {
    const wrapper = mount(Sample, {
      mocks: {
        $store: {
          state: {
            text: 'Sample'
          }
        },
      }
    });

    // wrapperのテキストは「Sample」である
    expect(wrapper.text()).toBe('Sample');
   
  });
});

mapStateなどのVuexメソッドでマッピングしてない場合はWrapper.mocksでモックできる。

$store.gettersの場合:

const wrapper = mount(Sample, {
  mocks: {
     $store: {
        getters: {
            'isShow': false,
            'user/isMember': true
          },
      },
   }
});

$store.dispatch$store.commitのモック:

const dispatch = jest.fn();
const commit = jest.fn();

const wrapper = mount(Sample, {
  mocks: {
     $store: {
        dispatch,
        commit
      },
   }
});

mapStateなどを利用しているコンポーネント

<template>
<div class="sample">{{ text }}</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
  name: 'Sample',
  computed: {
    ...mapState(['text'])
  }
}
</script>
import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import Sample from '@/components/Sample.vue';

const localVue = createLocalVue();
localVue.use(Vuex);

const store = new Vuex.Store({
  state: {
    text: 'Sample'
  }
});

describe('Sample.vue', () => {
  it('state.textの文字が表示される', () => {
    const wrapper = mount(Sample, { localVue, store });

    // wrapperのテキストは「Sample」である
    expect(wrapper.text()).toBe('Sample');
   
  });
});

createLocalVueでテスト用にVueを用意したらあとは殆どVuexの手順通り。
dispatchやcommitの検証はモック関数でもいいしスパイ(後述)でもいい。

サブモジュールのストア設定

Nuxt.jsでVuex使う場合、store/index.js以外は全部サブモジュール扱いになるので、テスト側でもそのように設定しておく必要がある。

store = new Vuex.Store({
  state: {},
  getters: {},
  actions: {},
  mutations: {},
  modules: {
    // store/category.js
    category: {
      namespaced: true,
      state: {},
      getters: {},
      actions: {},
      mutations: {}
    }
  }
});

Vue Routerを利用するコンポーネントの検証

$routeプロパティを参照しているコンポーネント

<template>
<div class="sample">
 <p v-if="$route.name === 'index'">Welcome</p>
 <p v-else>Hello</p>
</div>
</template>
<script>
export default {
  name: 'Sample'
}
</script>
import { mount } from '@vue/test-utils';
import Sample from '@/components/Sample.vue';

describe('Sample.vue', () => {
  it('$route.nameの値で表示を変更する', () => {
    const wrapper = mount(Sample, {
      mocks: {
        $route: {
          name: 'index'
        },
      }
    });

    // wrapperのテキストは「Welcome」である
    expect(wrapper.text()).toBe('Welcome');
   
  });
});

Vuexと同じく$routeプロパティを直接参照してるならモックで値を渡せばいい。

$route.pushを叩いているコンポーネント

<template>
<div class="sample">
 <button type="button" @click="handleClick">Home</button>
</div>
</template>
<script>
export default {
  name: 'Sample',
  methods: {
    handleClick() {
      this.$router.push({ name: 'index' });
    }
  }
}
</script>
import { mount } from '@vue/test-utils';
import Sample from '@/components/Sample.vue';

describe('Sample.vue', () => {

  it('クリック時に$route.pushが実行される', () => {
    const mockRouterPush = jest.fn();
    const wrapper = mount(Sample, {
      mocks: {
        $router: {
          push: mockRouterPush
        },
      }
    });

    wrapper.find('button').trigger('click');

    expect(routerPush).toHaveBeenCalledWith({
      name: 'index'
    });
   
  });
});

$routerのpushやreplaceメソッドはmock関数に置き換えて実行されたか検証する。

モック関数の利用

詳しいことは公式ドキュメント参照。

戻り値の設定はmountやsharllowMountの前に行うこと。

const mockRouterPush = jest.fn();

// 戻り値の指定
routerPush
  .mockReturnValueOnce(10)
  .mockReturnValueOnce('x')
  .mockReturnValue(true)
  .mockReturnValue(Promise.resolve('非同期な戻り値'));

// 実行回数の検証
expect(mockRouterPush.mock.calls.length).toBe(1);

// 引数の検証
expect(routerPush).toHaveBeenCalledWith({
  name: 'index'
});

スパイの利用

VueRouterのインスタンスでスパイを使う例

const router = new VueRouter({
  routes: [{ name: 'index', path: '/', params: {} }]
});

// ~~~ 省略 ~~~~

const routerReplace = jest.spyOn(router, 'replace');

expect(routerReplace).toHaveBeenCalledWith({
  name: 'index',
  params: {},
});

プラグインを利用しているコンポーネントの検証

Nuxt.jsのプラグインとかがVueインスタンスやコンテキストに何かしら関数を注入していて、

export default ({ app }, inject) => {
  inject('myInjectedFunction', (string) => console.log('That was easy!', string))
}

それをコンポーネントで利用している。とする

<template>
<div class="sample">Sample</div>
</template>
<script>
export default {
  name: 'Sample',
  mounted(){
    this.$myInjectedFunction('works in mounted')
  },
}
</script>

叩いてるのはプロパティなので、テストではモックで置き換えて検証できる

import { mount } from '@vue/test-utils';
import Sample from '@/components/Sample.vue';

describe('Sample.vue', () => {
  it('注入されたプラグインの関数が実行される', () => {
    const $myInjectedFunction = jest.fn();
    const wrapper = mount(Sample, {
      mocks: {
        $myInjectedFunction,
      }
    });

    expect($myInjectedFunction).toHaveBeenCalledWith(
      'works in mounted'
    );
   
  });
});

async/awaitを利用しているコンポーネントの検証

コンポーネント内の関数でAPIを叩いてデータを得るみたいなやつ

<template>
<div class="sample" v-if="posts.length">
  <p class="post" v-for="post in posts" :key="post.id">{{ post.title }}</p>
</div>
</template>
<script>
export default {
  name: 'Sample',
  data() {
    return {
      posts: []
    }
  },
  created(){
    this.getPosts()
  },
  methods: {
    async getPosts() {
      try {
        const response = await this.$_api.get('/posts', { params: { limit: 10 } });
        this.posts = response.data;
      } catch (error) {
        this.error = error;
      }
    }
  }
}
</script>

基本はJestのドキュメントにある通りで、APIを叩いてる処理をモックにして、戻り値をmockReturnValueで設定しておけば再現できる。
コンポーネントが非同期処理をしている場合は、テストの方でその処理が終わるまで待たないと設定した戻り値が反映されない。

import { mount } from '@vue/test-utils';
import Sample from '@/components/Sample.vue';

const windowOpen = jest.fn();
windowOpen.mockReturnValue({ opener: null });

global.open = windowOpen;

describe('Sample.vue', () => {
  let wrapper;
  let mockApiGet = jest.fn();

  afterEach(() => {
    mockApiGet.mockReset();
    wrapper.destroy();
  });

  it('APIから得た記事データが表示される', async () => {
    mockApiGet.mockReturnValue({
      data: [
        { id: 1, title: 'Item1'},
        { id: 1, title: 'Item2'}
      ]
    });
    wrapper = await mount(Sample, {
      mocks: {
        $_api: {
          get: mockApiGet
        }
      },
    });

    expect(mockApiGet).toHaveBeenCalledWith('/posts', {
      params: { limit: 10 }
    });
    expect(wrapper.findAll('.post').length).toBe(2);
   
  });
});

テストでUIフレームワークを有効にする

コンポーネントでBootstrapVueのUIを使っている例。

<template>
<div class="sample">
<b-card
    title="Card Title"
    img-src="https://picsum.photos/600/300/?image=25"
    img-alt="Image"
    img-top
    tag="article"
    style="max-width: 20rem;"
    class="mb-2"
  >
    <b-card-text>
      Some quick example text to build on the card title and make up the bulk of the card's content.
    </b-card-text>

    <b-button href="#" variant="primary">Go somewhere</b-button>
  </b-card>
</div>
</template>
<script>
export default {
  name: 'Sample'
}
</script>

ローカル登録

import { mount, createLocalVue } from '@vue/test-utils';
import BootstrapVue from 'bootstrap-vue'

const localVue = createLocalVue();
localVue.use(BootstrapVue)
import { mount, createLocalVue } from '@vue/test-utils';
import { BCard, BCardText, BButton } from 'bootstrap-vue';

const localVue = createLocalVue();
localVue.component('b-card', BCard);
localVue.component('b-card-text', BCardText);
localVue.component('b-button', BButton);
import { mount } from '@vue/test-utils';

const wrapper = mount(Sample, {
  stubs: {
     BCard: true,
     BCardText: true,
     BButton: true
  }
});

setupFilesAfterEnvの利用

いちいち登録するのが面倒ならこれで。

import Vue from 'vue';
import BootstrapVue from 'bootstrap-vue';
Vue.use(BootstrapVue);
setupFilesAfterEnv: ['<rootDir>/test/jest.setup.js']

window.openを叩くコンポーネントの検証

イベントハンドラで別窓開くやつ。

<template>
<div class="sample">
 <button type="button" @click="handleClick">Open Google</button>
</div>
</template>
<script>
export default {
  name: 'Sample',
  methods: {
    handleClick() {
      const otherWindow = window.open('https://google.com', '_blank');
      otherWindow.opener = null;
    }
  }
}
</script>
import { mount } from '@vue/test-utils';
import Sample from '@/components/Sample.vue';

const windowOpen = jest.fn();
windowOpen.mockReturnValue({ opener: null });

global.open = windowOpen;

describe('Sample.vue', () => {

  it('クリックでgoogleの別窓を開く', () => {
    const $myInjectedFunction = jest.fn();
    const wrapper = mount(Sample);

    wrapper.find('button').trigger('click');

    expect(windowOpen).toHaveBeenCalledWith(
      'https://google.com',
      '_blank'
    );
   
  });
});

jest-environment-jsdom-globalを使っているとwindow.open = global.openとなり、モック関数で置換できる。
Nuxt.jsのprocess.clientで判別かけている場合、テスト時はfalseになるので、テスト時も実行させるなら条件を!prosess.serverにする。

localStorage/sessionStorage操作の検証

ストレージをモックするクラスを用意する例。

export default class {
  constructor() {
    this.store = {};
  }

  clear() {
    this.store = {};
  }

  getItem(key) {
    return this.store[key] || null;
  }

  setItem(key, value) {
    this.store[key] = value.toString();
  }

  removeItem(key) {
    delete this.store[key];
  }
}
<template>
<div class="sample">
 <div class="sample">Sample</div>
</div>
</template>
<script>
export default {
  name: 'Sample',
  mounted() {
    localStorage.setItem('test', 'sample');
  }
}
</script>
import StorageMock from '@/test/mock/Storage';

// モックで置き換え
global.localStorage = new StorageMock();
global.sessionStorage = new StorageMock();

// スパイ
const setItem = jest.spyOn(StorageMock , 'setItem');

// テスト
expect(setItem).toHaveBeenCalledWith('test', 'sample');
expect(global.localStorage.getItem('test')).toBe('sample');

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください