Vue.js アプリでユニットテストを書くには、Vue Test Utils
や Jest
など、知っておくべきことがそれなりにあります。
現在、Vue CLI でアプリを作っていますが、ユニットテストを書くために色々と調べないといけませんでした。
今回はその過程で理解した Vue.js でのユニットテストの基本を以下にまとめます。
Vue.js のユニットテスト
まず、Vue.js では何を「ユニットテスト」として考えるのかを整理します。
ユニットテストの単位
Vue.js アプリは、複数のコンポーネントで構成され、それぞれのコンポーネントが連動しながら動きます。
そのため、ユニットテストの単位は「コンポーネント」となり、コンポーネントごとにテストを書いていきます。
何をテストすべきか?
コンポーネントごとにユニットテストを書くということですが、コンポーネントのどの部分に対してテストを書くべきでしょうか?
もちろん、細かいことはそれぞれの開発方針によって異なりますが、基本的には、**パブリックインターフェースの部分(インプット / アウトプット)**についてユニットテストを書くべきです。
ユニットテストの目的は、各コンポーネントが意図したとおり動くかどうかです。なので、内部のビジネスロジックや各関数を1行1行気にするのではなく、インプットに応じた適切なアウトプットを得られるかどうかに着目します。
Vue コンポーネントの具体的なインプット・アウトプットの例は次のとおりです。
インプットの例
- コンポーネントの
data
- コンポーネントの
props
- ユーザのアクション(ボタンクリックなど)
- ライフサイクルメソッド(
mounted()
,created()
など) - ステート管理のデータ
- ルーティングのパラメータ
「Unit Testing in Vue: What to Test? - Vue Test Utils」より引用
アウトプットの例
- DOMへの描画
- コンポーネントから呼び出すイベント
- ルーティングの変化
- ステートの更新
- 子コンポーネントとの連動
「Unit Testing in Vue: What to Test? - Vue Test Utils」より引用
Vue.js のテストツール
続いて、Vue.js で使われる一般的なテストツールを整理します。
Vue Test Utils
Vue.js では一般的に、標準テストライブラリの Vue Test Utils
を使ってテストを書きます。
具体的な書き方は後述しますが、テストコードは次のような感じで書きます。
「Guide - Vue Test Utils」より引用
import { mount } from '@vue/test-utils'
import Counter from './counter'
describe('Counter', () => {
// Now mount the component and you have the wrapper
const wrapper = mount(Counter)
it('renders the correct markup', () => {
expect(wrapper.html()).toContain('<span class="count">0</span>')
})
// it's also easy to check for the existence of elements
it('has a button', () => {
expect(wrapper.contains('button')).toBe(true)
})
})
Jest
Jest は Facebook によって作られたオープンソースの JavaScript テストフレームワークです。
Vue.js のアプリでは、この Jest を使って Vue Test Utils
で書いたテストコードを実行するのが一般的です(テストランナー)。
公式ドキュメントでは、mocha-webpack
というテストランナーも紹介されていますが、 Jest の方が初期設定が簡単(らしい)ので、以下では Jest を使っていきます。
Vue CLI のユニットテスト
続いて、実際に Vue CLI でユニットテストを書くために必要なことを整理します。
コード例はこちら(GitHub)
1. Vue CLI アプリ作成
まず Vue CLI でアプリを作成します。
Vue CLI をインストールしてない場合は、そのインストールからおこないます。(ついでに @vue/cli-init
もインストールします。)
sudo npm install -g @vue/cli @vue/cli-init
バージョン確認できれば、正常にインストールできています。
vue --version
@vue/cli 4.0.5
インストール後、 webpack-simple
でアプリを作成します。
vue init webpack-simple [APP_NAME]
2. Vue Test Utils & Jest のインストール
アプリのディレクトリに移動し、Vue Test Utils と Jest をインストールします。
cd [APP_NAME]
npm install --save-dev jest @vue/test-utils
3. Jest の設定
テストランナーとして Jest を利用するために、package.json
の scripts
に設定を追加します。
{
"scripts": {
"test": "jest"
}
}
また、Jest に .vue
ファイルの扱い方を知ってもらうために、vue-jest
もインストールします。
npm install --save-dev vue-jest
インストール後、package.json
に jest
の設定を追加します。
{
"jest": {
"moduleFileExtensions": [
"js",
"json",
"vue"
],
"transform": {
".*\\.(vue)$": "vue-jest"
},
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/src/$1"
}
}
}
moduleNameMapper
を上のように書くことで、src/
のパスを @/
で書けるようになります。
// ../src/App -> @/App
import Component from '@/App'
最後に、ESモジュールを使えるようにするために babel-jest
をインストールします。
npm install --save-dev babel-jest
インストール後、package.json
に babel-jest
の設定を追加します。
{
"jest": {
"transform": {
"^.+\\.js$": "<rootDir>/node_modules/babel-jest"
}
}
}
テスト環境でこの設定が有効になるように、.babelrc
を次のように更新します。
{
"presets": [["env", { "modules": false }]],
"env": {
"test": {
"presets": [["env", { "targets": { "node": "current" } }]]
}
}
}
これで設定は完了です。
4. テストファイルの構成
Jest はデフォルトで、アプリのディレクトリ配下にある .spec.js
もしくは .test.js
のテストを実行していきます。
また、Jest は __tests__
ディレクトリ内にテストコードを置くことを推奨しています。その例にならって、 ルートディレクトリに __tests__
を作成します。
mkdir __tests__
5. ユニットテストを書く
それでは実際に __tests__
に App.vue
のテストコードを書いてみます。
import { mount } from '@vue/test-utils';
import Component from '@/App.vue'
describe('Testing App component', () => {
it('is a Vue instance', () => {
const wrapper = mount(Component)
expect(wrapper.isVueInstance).toBeTruthy()
})
})
mount
メソッドにコンポーネントを渡せば、Vue インスタンスと DOM ノードが返されます。この返り値の wrapper
を使って様々なテストコードを書いていきます。
書いたテストコードは npm test
で実行できます。
npm test
コード例はこちら(GitHub)
Vue Test Utils の書き方
最後に、Vue Test Utils でのテストの書き方について基本的なことをざっと整理します。
より細かいことは公式ドキュメントをご参照ください。
mount vs shallowMount
mount
メソッドと同様に、shallowMount
メソッドでも wrapper
を受け取れます。
mount
と shallowMount
の違いは、子コンポーネントをスタブ化できることです。
ユニットテストでは基本的に他コンポーネントの影響を排除すべきなので、多くの場合では、子コンポーネントの描画を無視できる shallowMount
を用いた方がよいでしょう。
beforeEach & afterEach
各テストケースで同じ wrapper
を定義している場合は、beforeEach
メソッド内でDRYに書けます。(毎テスト後の処理は afterEach
に書きます。)
import { shallowMount } from '@vue/test-utils';
import Component from '@/App.vue'
let wrapper
beforeEach(() => {
wrapper = shallowMount(Component)
})
afterEach(() => {
wrapper.destroy();
})
describe('Testing App component', () => {
it('is a Vue instance', () => {
expect(wrapper.isVueInstance).toBeTruthy()
})
})
data の更新
例えば、コンポーネントの data.msg
の値を更新したい場合は、wrapper.vm
を使って次のように書きます。
const wrapper = shallowMount(Component)
wrapper.vm // the mounted Vue instance
wrapper.vm.msg = 'Hello World!'
イベントトリガーのテスト
DOMイベントのテストは、まず発火させたい処理を jest.fn()
でモック関数として定義します。そのモック関数が、wrapper.find('button').trigger('click')
などのイベントトリガーによって正しく呼び出されるかを検証します。
「Testing Dom events in Vue.js using Jest and vue-test-utils - Reactgo」より引用
import { shallowMount } from '@vue/test-utils';
import Component from '@/App.vue'
describe('Testing native dom events', () => {
const wrapper = shallowMount(Component)
it('calls increment method when button is clicked', () => {
const increment = jest.fn() // mock function
// updating method with mock function
wrapper.setMethods({ increment })
//find the button and trigger click event
wrapper.find('button').trigger('click')
expect(increment).toBeCalled()
})
})
vue-router のセットアップ
vue-router でルーティングを設定している場合、次のセットアップが追加で必要です。
<router-view>
を使ってるコンポーネント
App.vue
のように、 <router-view>
で描画を変更しているトップコンポーネントでは、セットアップ時に VueRouter
と createLocalVue
を追加して、shallowMount
の引数として使います。
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueRouter from 'vue-router'
import Component from '@/App.vue'
const localVue = createLocalVue()
localVue.use(VueRouter)
const router = new VueRouter()
let wrapper
beforeEach(() => {
wrapper = shallowMount(Component, {
router
})
})
// 以下、省略
<router-link>
を使ってるコンポーネント
<router-link>
を使っているコンポーネントでは、 router-link
と router-view
をスタブ化させます。
import { shallowMount } from '@vue/test-utils'
import Component from '@/components/Component'
let wrapper
beforeEach(() => {
wrapper = shallowMount(Component, {
stubs: ['router-link', 'router-view']
})
})
// 以下、省略
axios のセットアップ
axios
でCRUD処理を呼び出している場合は、同処理をモック化させます。
Jest はデフォルトで __mocks__
ディレクトリにある [モジュール名].js
をモックモジュールとして扱うので、そこに axios.js
を追加します。
export default {
get: () => Promise.resolve({ data: 'value' })
}
CRUD処理を呼び出しているコンポーネントのテストにモック化した axios
を追加します。
import { shallowMount } from '@vue/test-utils'
import Component from '@/components/Component'
// デフォルトで `__mocks__` 配下の `axios.js` を探しにいく
jest.mock('axios')
Vuex テストのセットアップ
Vuex でステートマネジメントしているコンポーネントでは、セットアップ時に Vuex
と createLocalVue
を追加して、shallowMount
の引数として使います。
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from "vuex"
import Component from '@/App.vue'
const localVue = createLocalVue()
localVue.use(Vuex)
let wrapper
let store
let actions
let mutations
let state
let getters
beforeEach(() => {
actions = {}
mutations = {}
state = {
entries: {
sales: 1000,
cost: 500
},
getters = {
entries(state) { return state.entries }
}
store = new Vuex.Store({
actions,
mutations,
state,
getters
})
wrapper = shallowMount(Component, {
store,
localVue,
})
})
afterEach(() => {
wrapper.destroy();
})
describe('Testing App component', () => {
it('is a Vue instance', () => {
expect(wrapper.isVueInstance).toBeTruthy()
})
// ...
})
actions のトリガーテスト
イベントトリガーの時と同様に、actions
も jest.fn()
を使って呼び出しを検証します。
// ...
beforeEach(() => {
// ...
actions = {
updateEntries: jest.fn()
}
// ...
})
// ...
describe('Testing App component', () => {
// ...
it('calls updateEntries when inputs are changed', () => {
wrapper.vm.sales = 2000
expect(actions.updateEntries).toHaveBeenCalled()
})
})
factory メソッドでデータを用意
テストごとにステートなどの設定を変えたい場合は、beforeEach
でなく、factory メソッドで wrapper
を用意するのが効率的です。
import { shallowMount, createLocalVue } from '@vue/test-utils'
import Vuex from "vuex"
import Component from '@/App.vue'
const localVue = createLocalVue()
localVue.use(Vuex)
let store
let actions
let mutations
let state
let getters
const exampleEntries = {
sales: 1000,
cost: 500
}
// factory メソッド
const factory = (entries = exampleEntries) => {
actions = {}
mutations = {}
state = {
entries: {
...entries
}
}
getters = {
entries(state) { return state.entries }
}
store = new Vuex.Store({
actions,
getters,
mutations,
state
})
return shallowMount(Component, {
store,
localVue
})
}
describe('Testing App component', () => {
it('is a Vue instance', () => {
const wrapper = factory()
expect(wrapper.isVueInstance).toBeTruthy()
})
// ...
})