Posted at

Vue.jsのテストをモダンにする

More than 1 year has passed since last update.


はじめに

1年半前くらいに弊社のVue.jsプロジェクトのユニットテスト環境を「Karma + Mocha + power-assert」で作成し、現在までメンテナンスしながら使っていたが、「Jest + vue-test-utils」が非常に良さそうなので、環境を全て作り直すことにした

作り直したコードですが、弊社のコードは公開できないので、プライベートでメンテナンスしている以下のリポジトリで確認できる

https://github.com/kurosame/vuejs-boilerplate

Vue.jsとVuexとTypeScriptを使っている



テスト対象

今回は以下をテスト対象とした


  • Vuex


    • Action

    • Mutation

    • Getter

    • (State) ⇒ 初期値を設定する時に分岐などのロジックを入れた場合があれば書く



  • Vue.js


    • コンポーネント全て





そもそもなぜテスト環境を作り直そうと思ったのか

Karmaを使っていて、以下の問題を抱えていた


  • なんか遅い

    理由は調べてないけど、初回起動時にテスト走らすまでが遅かった(テストが動くまで30秒くらい待ってた)


  • カバレッジのマーカーがずれてる

    babel-plugin-istanbulを使ってカバレッジを取っているのだが、通ってない箇所を表示するマーカーの位置が違う場所になってる

    (この現象はTypeScriptに移行してから起きるようになった

    TypeScriptをECMAScriptに変換して、babel-plugin-istanbulを使えるようにしたが、マーカーのバグだけは直せなかった)


  • ログが見づらい

    なんか色々がログが出過ぎてて、見づらい



  • 色々プラグインをインストールしてた

    Karma関連だけでざっと以下を入れていた、これに加えてMochaやpower-assertを入れてる感じ


    • babel-plugin-istanbul

    • karma

    • karma-coverage

    • karma-mocha

    • karma-phantomjs-launcher

    • karma-sourcemap-loader

    • karma-spec-reporter

    • karma-webpack



  • ブラウザテストでPhantomJSを使っていたが、以下の通り、リポジトリがアーカイブされ、開発も終了した

    https://github.com/ariya/phantomjs




Jestにしてみて


  • 早い

    並列でテストが動作する


  • プラグインがほとんど不要で、Jestに内製されている

    カバレッジやスナップショット機能など


  • TypeScriptに対応している

    ts-jestを使う


  • ログが見やすいと思う


  • 日本語のドキュメントが分かりやすくて良い




Jestの設定について

公式でゼロ・コンフィギュレーションと書いてあるだけあって、設定がほとんど不要

と言いたいが、Vue.js+TypeScriptで書きたい、カバレッジやスナップショットを取りたいとなるとある程度の設定は必要


jest.config.js

module.exports = {

moduleFileExtensions: ['vue', 'js', 'ts'],
testMatch: ['<rootDir>/test/unit/specs/**/*.ts'],
// Vue.jsとTypeScript用のトランスパイラー
transform: {
'^.+\\.vue$': 'vue-jest',
'^.+\\.ts$': 'ts-jest'
},
// webpackのエイリアスを設定している場合は必要
// alias: { '@': path.join(__dirname, 'src') } 的なやつ
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1'
},
// Vue.jsでスナップショットを取る場合に必要
snapshotSerializers: ['jest-serializer-vue'],
// カバレッジ関連の設定
collectCoverageFrom: [
'<rootDir>/src/components/**/*.vue',
'<rootDir>/src/pages/**/*.vue',
'<rootDir>/src/vuex/actions/**/*.ts',
'<rootDir>/src/vuex/getters/**/*.ts',
'<rootDir>/src/vuex/modules/**/*.ts',
'<rootDir>/src/vuex/state/**/*.ts'
],
coverageDirectory: '<rootDir>/test/unit/coverage',
coverageReporters: ['html', 'text-summary'],
// 実行結果を詳細に出す
verbose: true
}



Karmaの設定との比較

以下は1年半前に設定したkarma.config.js(当時はTypeScriptではなくBabel)

なんかバグるとpreprocessorsやfilesをガチャガチャ設定してた気がする

1年半前に設定したものなので比較対象として微妙だが、Jestと比べて色々分かりづらいように見える(preprocessorsとか)

また、Jestとの主な違いとして

・frameworksの設定

 Karma上で動くテストフレームワークやアサーション等を指定する必要がある

・preprocessors

 ファイルに応じて指定が必要

・ブラウザの指定

 Karmaは実際のブラウザ上でテストができる

 たぶんJestの場合はJest標準の機能で実際のブラウザ上でテストはできないはず


1年半前のkarma.config.js

module.exports = (config) => {

config.set({
frameworks: ['mocha', 'sinon-chai'],
files: [
'../../node_modules/babel-polyfill/dist/polyfill.js',
'../../src/**/*.vue',
'../../src/**/*.js',
{
pattern: 'specs/**/*.js',
watched: false
}
],
preprocessors: {
'../../src/**/*.vue': ['webpack'],
'../../src/**/*.js': ['webpack'],
'specs/**/*.js': ['webpack', 'sourcemap']
},
reporters: ['spec', 'coverage'],
browsers: ['PhantomJS'],
coverageReporter: {
dir: './coverage',
reporters: [
{ type: 'text-summary' },
{ type: 'html' }
]
}
})
}



Vue.jsのコンポーネントのユニットテストについて

1年半前の時はVuexのAction, Mutation, GetterやUtility関数などはテストを書いていたが、

コンポーネントのテストは書いていなかった

理由は

・Vue.js側ではロジックは書かずにVuexのGetterでstateを表示用に加工したプロパティをprops経由で受け取り表示するだけにしようと思っていた

・UIのテストは微妙なズレとかを自動テストで検知できないので、目視で確認しようと思っていた

・Vue.jsのユニットテストフレームワークでいいのがなかった

など

でも実際は以下だったので、失敗したなって思っている

・あるコンポーネント特有のロジックはVuexで処理せずにそのコンポーネントのmethodsオプションやcomputedオプションで算出している(このロジックはテストで自動化すべきだった)

・目視でのテストは結局必要だが、全て目視確認するのではなく、スナップショットを導入してある程度は自動化すべきだった

また、Vue.jsの公式のテストフレームワークが公開されて、それも追い風となり

ユニットテストを書くことにした



vue-test-utils

Vue.jsの公式でメンテナンスされているVue.jsコンポーネントのテストフレームワーク

まだβ版だが、今回使ってみて今の所問題なく使えている

ちなみにavoriazというテストフレームワークを開発している方がvue-test-utilsをメインで開発しており、

まだvue-test-utilsがβ版であることで使用を躊躇する方がいればavoriazを使うと良いと思う

将来的にavoriazからvue-test-utilsに移行することになっても、比較的移行しやすいはず

次のページからは実際にテストコードをのせる

Vuex周りは1年半前のテストコードを比較しながら見てみたいと思う



Getter(変更前)

以下のコードがテスト対象

特に加工せずGetterはStateのcountを返すのみ


counter.ts

import { State } from '@/vuex/state/counter'

import { GetterTree } from 'vuex'

const getters: GetterTree<State, any> = {
count: (state: State): number => state.count,
}


まずは旧コード

Storeを作る際にgettersとStateを渡し、gettersのcountがStateのcountと同じことをテストしている


1年半前のGetterのユニットテストコード

import Vue from 'vue'

import Vuex from 'vuex'
import * as assert from 'power-assert'
import getters from '@/vuex/getters/counter'

Vue.use(Vuex)

export class State {
count: number = 1
}

it('Compute the count', () => {
const store = new Vuex.Store({ getters, state: new State() })

assert.equal(store.getters.count, 1)
})


テストはできているので、問題ないと思うが

VuexのテストにVueを使っている点やわざわざStateを用意している点が微妙かなって思ってる



Getter(変更後)

次が今回修正したコード

Vueに依存せず、Storeも使わず、Stateも用意していない


Getterの変更後ユニットテストコード

import getters from '@/vuex/getters/counter'

test('Compute the count', () => {
const wrapper = (g: any) => g.count({ count: 1 })

expect(wrapper(getters)).toEqual(1)
})


GetterのTypeScriptの型定義はこのようになっている

export type Getter<S, R> = (state: S, getters: any, rootState: R, rootGetters: any) => any;

なので、このように渡してあげればテストすることが可能

getters.count(state, getters, rootState, rootGetters)

// 今回の例だとstateのみ必要なので、以下のようになる
getters.count({ count: 1 }, null, null, null)

Getter関数は引数が4つ定義されており、使ってないものに毎回nullやundefinedを渡すのが冗長に思えたので、any型でラップした

実際はJavaScriptなので4つ全部渡さなくてもテストは動くのだが、TypeScript上はエラーになるのでこのようにした

const wrapper = (g: any) => g.count({ count: 1 })



Mutation(変更前)

以下のコードがテスト対象

引数で受け取ったvalueをStateのcountにセットしている


counter.ts

import { State } from '@/vuex/state/counter'

import { ADD_COUNT } from '@/vuex/types'
import { MutationTree } from 'vuex'

const mutations: MutationTree<State> = {
[ADD_COUNT](state: State, value: number) {
state.count += value
}
}


まずは旧コード

Storeにテスト対象のmutationsをセットして、Storeに対してcommitすることでMutationを実行し、結果に対してアサーションを行う


1年半前のMutationのユニットテストコード

import Vue from 'vue'

import Vuex from 'vuex'
import * as assert from 'power-assert'
import modules from '@/vuex/modules/counter'
import { State } from '@/vuex/state/counter'
import { ADD_VALUE } from '@/vuex/types'

Vue.use(Vuex)

it('Set the state.count', () => {
const store = new Vuex.Store({
state: new State(),
mutations: modules.mutations
})
store.commit(ADD_VALUE, 1)

assert.equal(store.state.count, 1)
})




Mutation(変更後)


Mutationの変更後ユニットテストコード

import modules from '@/vuex/modules/counter'

import { ADD_COUNT } from '@/vuex/types'

test('Set the state.count', () => {
const state = { count: 1 }
const wrapper = (m: any) => m[ADD_COUNT](state, 1)
wrapper(modules.mutations)

expect(state.count).toEqual(2)
})


MutationのTypeScriptの型定義はこのようになっている

export type Mutation<S> = (state: S, payload: any) => any;

Getterの時と同様にany型でラップした

const wrapper = (m: any) => m[ADD_COUNT](state, 1)



Action(変更前)

非同期処理を含むコードになっている

import { State } from '@/vuex/state/counter'

import { ADD_COUNT } from '@/vuex/types'
import axios from 'axios'
import { ActionContext, ActionTree } from 'vuex'

const actions: ActionTree<State, any> = {
[ADD_COUNT](context: ActionContext<State, any>) {
axios
.get('/api')
.then(res => context.commit(ADD_COUNT, res.data.count))
.catch(() => console.error('API response error'))
}
}

以下が旧テストコードだが、相当ひどいコードでよく使い続けてたなってレベル

途中で見直しを入れるべきだった

やりたいことはaxiosをsinonでモック化して、Storeのcommitが実行されているかをチェックしたかった

commitをモック化すれば良かったのに、わざわざ確認用のMutationを用意して、確認している

Actionを呼ぶ時もなぜかnew Vueして、それ経由(vm)で呼んでて意味が分からない


1年半前のActionのユニットテストコード

import Vue from 'vue'

import Vuex, { mapActions } from 'vuex'
import axios from 'axios'
import * as assert from 'power-assert'
import * as sinon from 'sinon'
import * as Bluebird from 'bluebird'
import actions from '@/vuex/actions/counter'
import { State } from '@/vuex/state/counter'
import { ADD_VALUE } from '@/vuex/types'

Vue.use(Vuex)

const store = new Vuex.Store({
actions,
state: new State(),
mutations: {
[ADD_VALUE](state: State, count) {
state.count = count
}
}
})

const vm = new Vue({
store,
methods: {
...mapActions({
addValue: ADD_VALUE
})
}
})

let stub: sinon.SinonStub

beforeEach(stub = sinon.stub(axios, 'get'))
afterEach(() => stub.restore())

it('ADD_VALUE - axios resolved', async () => {
const resolved = Bluebird.resolve({
data: { count: 2 }
})
stub.returns(resolved)

await vm.addValue()
resolved.then(() => assert.equal(store.state.count, 2))

it('ADD_VALUE - axios rejected', async () => {
const rejected = Bluebird.reject({
new Error('error')
})
stub.returns(rejected)

await vm.addValue()
rejected.catch((err: Error) => assert.equal(err.message, 'error'))
})




Action(変更後)

Vue.js, Vuexへの依存を排除し、Storeのcommitをモック化してアサーションを行っている

axiosをモック化するのにmoxiosというパッケージが使いやすそうだったので、sinonからmoxiosに変更した


Actionの変更後ユニットテストコード

import actions from '@/vuex/actions/counter'

import { ADD_COUNT } from '@/vuex/types'
import moxios from 'moxios'

let mockCommit: jest.Mock
let spyErr: jest.SpyInstance

beforeEach(() => {
moxios.install()
mockCommit = jest.fn()
spyErr = jest.spyOn(console, 'error')
})
afterEach(() => {
moxios.uninstall()
mockCommit.mockReset()
spyErr.mockReset()
})

describe('Run the ADD_COUNT', () => {
test('Call the commit', done => {
moxios.stubRequest('/api', {
status: 200,
response: { count: 2 }
})

const wrapper = (a: any) => a[ADD_COUNT]({ commit: mockCommit })
wrapper(actions)

moxios.wait(() => {
expect(mockCommit).toBeCalled()
expect(mockCommit.mock.calls[0][0]).toEqual(ADD_COUNT)
expect(mockCommit.mock.calls[0][1]).toEqual(2)
done()
})
})

test('Output the console.error', done => {
moxios.stubRequest('/api', {
status: 400
})

const wrapper = (a: any) => a[ADD_COUNT]({ commit: mockCommit })
wrapper(actions)

moxios.wait(() => {
expect(console.error).toBeCalled()
expect(spyErr.mock.calls[0][0]).toEqual(
'API response error'
)
done()
})
})
})


API成功時はStoreのcommitをモック化してアサーションを行う

mockCommit = jest.fn()

moxios.wait(() => {
expect(mockCommit).toBeCalled()
expect(mockCommit.mock.calls[0][0]).toEqual(ADD_COUNT)
expect(mockCommit.mock.calls[0][1]).toEqual(2)
done()
})

API失敗時は今回の例ではconsole.errorが実行されることを確認する

console.errorもJestのspyOnという機能でモック化している

spyErr = jest.spyOn(console, 'error')

moxios.wait(() => {
expect(console.error).toBeCalled()
expect(spyErr.mock.calls[0][0]).toEqual(
'API response error'
)
done()
})



moxiosのwaitについて

moxiosを使う場合、wait関数内でアサーション処理をすることが多いと思うが、この関数は内部的にsetTimeoutを実行しているだけらしいので、

Actionで重い処理があった場合はwait内に入っても、処理が終わっておらずエラーになることがある

moxios.wait(() => {

// アサーション処理など
...
})

async/awaitなどを使って非同期処理を制御するか

waitの第2引数でdelayというsetTimeout関数に渡す時間を指定できるので、これを多めに指定して回避する必要がある



Vue.jsのユニットテスト

ここでようやくvue-test-utilsが登場する

vue-routerから直接呼ばれるような親コンポーネントと親から呼ばれる子コンポーネントで使う機能が少し異なる

まずは子コンポーネント



子コンポーネント

以下のようなコンポーネントとする


Child.vue

<template>

<div class="child">
<div>
<span class="count">{{ count }}</span>
<button class="add-count" @click="$emit('addCount')">ADD</button>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue'

export default Vue.extend({
name: 'Child',
props: ['count']
})
</script>


テストコードはこのようになる

import Child from '@/components/Child.vue'

import { mount } from '@vue/test-utils'

const wrapper = mount(Child, {
propsData: {
count: 1
}
})

// props経由で受け取ったcountが表示されているか
test('Data binding from the propsData.count to the count', () => {
expect(wrapper.html()).toContain('<span class="count">1</span>')
})

// ボタンのクリックイベントが正常に動いているか
test('Click the button.add-count will emit the addCount', () => {
expect(wrapper.emitted('addCount')).toBeUndefined()

wrapper.find('button.add-count').trigger('click')

expect(wrapper.emitted('addCount')).toBeTruthy()
expect(wrapper.emitted('addCount')[0]).toEqual([])
})

// 前回のスナップショットから変わってないか
test('Match the snapshot', () => {
expect(wrapper.html()).toMatchSnapshot()
})

vue-test-utilsのmountを使って、外部要因(propsなど)をラップする

mountはVueをextendsしており、Vueの一通りの機能は使える

おそらく上記のテストコードを読むだけで、何をやっているか分かるかなーと思う



親コンポーネント

以下のようなコンポーネントとする

子コンポーネントと違いVuexが絡んでくるので、少し面倒

また、子コンポーネントを呼んでいて邪魔なので、スタブ化したい


Parent.vue

<template>

<div class="parent">
<child :count="count" @addCount="addCount"></child>
</div>
</template>
<script lang="ts">
import Child from '@/components/Child.vue'
import { ADD_COUNT } from '@/vuex/types'
import Vue from 'vue'
import { mapActions, mapGetters } from 'vuex'

export default Vue.extend({
name: 'Parent',
methods: {
...mapActions({
addCount: ADD_COUNT
})
},
computed: {
...mapGetters(['count'])
},
components: {
Child
}
})
</script>


テストコードはこのようになる

import Parent from '@/pages/Parent.vue'

import { createLocalVue, shallowMount } from '@vue/test-utils'
import Vuex from 'vuex'

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

const store = new Vuex.Store({
actions: {
addCount: jest.fn()
},
getters: {
count: () => 1
}
})

const wrapper = shallowMount(Parent, {
localVue,
store
})

test('Match the snapshot', () => {
expect(wrapper.html()).toMatchSnapshot()
})

createLocalVueはVueのコピーを作成している

このコピーにVuexなどのモジュールをインストールすることでVue本体を汚染せずに使える

const localVue = createLocalVue()

localVue.use(Vuex)

vue-test-utilsのshallowMountを使う

shallowMountもVueをextendsしており、Vueの一通りの機能は使える

mountと機能は似ているが、shallowMountは子コンポーネントをスタブ化する

実際にはテスト時には以下のようにChildコンポーネントを呼び出している所をコメントアウトする

<div class="parent">

<!---->
</div>

便利すぎて感動した



E2Eテスト

今回弊社のシステムではチャートを使った画面がメインなので、今の所E2Eテストは書かずに画面を見て動作確認しているが、

そのうち設定画面などを作ることになりそうなので、E2Eテストを書くことになると思う

色々調べて使いやすそうな感じがしたJest + Puppeteerを試した

E2Eテスト用のJest設定ファイル


jest.e2e.config.js

module.exports = {

moduleFileExtensions: ['vue', 'js', 'ts', 'json'],
testMatch: ['<rootDir>/test/e2e/specs/**/*.ts'],
transform: {
'^.+\\.vue$': 'vue-jest',
'^.+\\.ts$': 'ts-jest'
},
preset: 'jest-puppeteer',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1'
},
verbose: true
}

presetにjest-puppeteerというパッケージを使っている

コード中にAPIアクセスがあり、ローカルサーバーを立てる必要があったので、使っている

Jest + Puppeteerを使っている場合は、このパッケージが良いみたい

Jest + Puppeteer用の設定ファイル


jest-puppeteer.config.js

module.exports = {

server: {
command: 'webpack-dev-server --port 9000 --mode development',
port: 9000
}
}

webpack-dev-serverのオプションと設定ファイルのportオプションで2回ポート9000を指定していますが、

片方だけ指定だとエラーになりました

こちらはそのうち調査して修正します



E2Eのテストコード

import path from 'path'

import puppeteer, { Browser, Page } from 'puppeteer'

// Jestがデフォルト5秒でタイムアウトエラーになるので、30秒に引き上げている
jest.setTimeout(30000)

let browser: Browser
let page: Page

beforeAll(async () => {
browser = await puppeteer.launch({ headless: false, timeout: 0 })
page = await browser.newPage()
})
afterAll(() => {
browser.close()
})

beforeEach(async () => {
await page.goto('http://localhost:9000')
})

// ボタンをクリックして、APIリクエストがあるので1秒待って、スクリーンショットとアサーションを実行する
test('Click the button.add-count, update the count', async () => {
await page.click('.add-count')
await page.waitFor(1000)

await page.screenshot({
path: path.join(__dirname, '__screenshots__', 'add-count.png'),
fullPage: true
})

expect(await page.$eval('.count', v => v.textContent)).toEqual('1')
})

ボタンを押すだけの単純なテストケースだが、分かりやすいテストコードになっていると思う



E2EテストをCIで動かす時に注意する点

前ページのテストコードだとCircleCIなどのCIツールで動かした時にエラーで落ちる

実際にCIで使う際は以下のようにブラウザを起動する所で、CI用の起動オプションを変えることが必要

browser =

process.env.CI === 'true'
? await puppeteer.launch({
headless: true,
timeout: 0,
args: ['--no-sandbox']
})
: await puppeteer.launch({ headless: false, timeout: 0 })

また、Vue.jsのテストやビルドをCIでDockerを使う時に特に用意してなければ、circleci/nodeを使っていると思うが、

モジュールが足りずに動かない

DockerfIleを作成し、以下に変更し、DockerHubにPushしておいた


Dockerfile

FROM node:8.11.2

MAINTAINER kurosame

RUN apt-get update && \
apt-get install -yq --no-install-recommends \
libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 \
libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 \
libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 \
libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 \
libnss3




さいごに・感想


  • 今回のテストコードの例はどれも簡単なものを紹介したが、実際は弊社のプロジェクトに導入しており、今の所大きな問題もなくストレスもなくテストが書けている

  • JestはReactで使うイメージがあったが、Vue.jsでも問題なく使える

  • Vue.jsテストが思ってた以上に簡単に書けて感動した

  • もっとPuppeteerで色々書きたい

  • しばらくはこの構成でやっていけると思う