ユニットテストの書き方メモ。
サンプルコードのテストランナーは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-fourteen', testURL: 'https://example.com' };
observeDomのwarnが出る場合
observeDom: Requires MutationObserver support.
jsdom@14とjest-environment-jsdom-fourteenを使えばおk。
基本
コンポーネントが表示されているか
<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>'); }); });
mountやshallowMountで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メソッドを利用する。
DOMの反映を検証する場合は、$nextTickで変更を待つ。
it('テストの途中でプロパティ値を変更する', () => { wrapper = mount(Sample); // デフォルト expect(wrapper.text()).toBe('0'); // プロパティの変更 wrapper.setProps({ number: 100 }); // 変更後 wrapper.vm.$nextTick(() => { 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を利用する。
$root.$emitしたカスタムイベント
ルート要素に$emitしている場合もある
this.$root.$emit('bv::show::modal', 'sample-modal');
createWrapper
で$rootのためのwrapperを作る。
そうして擬似的に作ったルート要素のwrapperでemittedなど使って検証する
import { mount, createWrapper } from '@vue/test-utils'; import Sample from '@/components/Sample.vue'; describe('Sample.vue', () => { it('$root.$emitの検証', () => { const wrapper = mount(Sample); const rootWrapper = createWrapper(wrapper.vm.$root); // イベントが発行されたかどうか expect(rootWrapper.emitted('bv::show::modal')).toBeDefined() // イベントの実行回数 expect(rootWrapper.emitted('bv::show::modal').length).toBe(1) // イベントのペイロードを検証 expect(rootWrapper.emitted('bv::show::modal')[0]).toBe(['sample-modal']); }); });
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の検証はモック関数でもいいしスパイ(後述)でもいい。
Vuexモジュールのモック化
あるいは、jest.mock
でVuexをモック化する。
import Vuex from 'vuex'; jest.mock('vuex'); const localVue = createLocalVue(); localVue.use(Vuex); const wrapper = mount(Component, { localVue, });
サブモジュールのストア設定
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: {} } } });
サブモジュールにjest.mockを利用する
import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import Sample from '@/components/Sample/'; import rootStore from '@/store/index'; import * as subStore from '@/store/sub'; jest.mock('@/store/sub'); const localVue = createLocalVue(); localVue.use(Vuex); describe('何かのテスト', () => { let wrapper; let store; beforeEach(() => { store = new Vuex.Store({ ...rootStore, modules: { comment: { namespaced: true, ...subStore } } }); wrapper = mount(Sample, { localVue, store }); }); afterEach(() => { wrapper.destroy(); // モック全部リセットする jest.resetAllMocks(); }); it('actionの検証例', () => { // 戻り値の設定 subStore.actions.getPosts.mockReturnValue(Promise.resolve([])); // $store.dispatch('sub/getPosts', { id: 123 } ); expect(subStore.actions.getPosts.mock.calls[0][1]).toEqual({ id: 123 }); }); });
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' ); }); });
非同期処理を利用しているコンポーネントの検証
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: [] } }, async created(){ await 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で設定しておけば再現できる。
テスト側でもasync/awaitした上で、$nextTickによりVue側のDOM更新待ちをする。
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 } }); wrapper.vm.$nextTick(() => { 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-*を使っていると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');
Nuxt.jsプラグインのテスト
プラグインを自作する場合に。
// ここにプラグインの処理 const $_sample = function(options = {}) { // アロー関数使うとthisがundefinedになる console.log(options); console.log(this.$route.path); // VueRouterの値が使いたいという気持ちの表れ return 'Hello Sample'; } // テスト向けのexport export const SamplePlugin = { install(Vue) { Vue.prototype.$_sample = $_sample; } }; // 統合された注入 export default (ctx, inject) => { inject('_sample', $_sample); };
import { SamplePlugin } from '@/plugins/Sample'; import { mount, createLocalVue } from '@vue/test-utils'; const localVue = createLocalVue(); localVue.use(SamplePlugin); const Test = { mounted() { this.sample = this.$_sample({ key1: 'value1', key2: 'value2' }); }, template: '<div />' }; describe('plugins/Sample.js テスト', () => { it('test', () => { const wrapper = mount(Test, { localVue, mocks: { $route: { name: 'test', path: '/', query: {} } } }); expect(wrapper.vm.sample).toBe('Hello Sample'); }); });
wrapper.vmから直接プラグインを叩くこともできる
expect(wraper.vm.$_sample({ key1: 'value1', key2: 'value2' })).toBe('Hello Sample');