仕事でVue.jsを使ったSPAを開発しているのだが、フロントエンドの単体テストは行わず、E2Eテストを重視してSelenium(Selenide)を使ったテストコードを書いてきた。
ところが、最近フロントエンドに大幅な修正が必要になり、E2Eだけではテストに時間がかかりすぎるようになったため、ずっと放置してきたフロントエンドの単体テストにトライすることした。
というか、ぶっちゃけると開発しているアプリがVuexとTypeScriptを使っているのだが、コンポーネントをテストするときにVuexをモックにすることができず、単体テストがうまくコーディングできない課題を解決することができずに放置していたのだが、なんとか期待する形になったので、今回はその内容を記事にまとめる。
想定する環境
- Vue.js (v2.6)
- Vuex (v3.4.0)
- TypeScript
- Jest
サンプルプロジェクト作成
@vue/cli
でsampleプロジェクトを作成。以下を選択したくらいでそれ以外はデフォルトのまま。
$ vue create sample
Vue CLI v4.5.13
? Please pick a preset:
Default ([Vue 2] babel, eslint)
Default (Vue 3) ([Vue 3] babel, eslint)
❯ Manually select features
Vue CLI v4.5.13
? Please pick a preset: Manually select features
? Check the features needed for your project:
◉ Choose Vue version
◯ Babel
◉ TypeScript
◯ Progressive Web App (PWA) Support
◯ Router
◉ Vuex
◯ CSS Pre-processors
◯ Linter / Formatter
❯◉ Unit Testing
◯ E2E Testing
? Please pick a preset: Manually select features
? Check the features needed for your project: Choose Vue version, TS, Vuex, Linter, Unit
? Choose a version of Vue.js that you want to start the project with (Use arrow keys)
❯ 2.x
3.x
? Pick a unit testing solution:
Mocha + Chai
❯ Jest
$ cd sample
$ npm run test:unit
テストしたいプロダクトコード
まずはテスト対象となるプロダクトコードを掲載
テストしたいコンポーネント
<template>
<div class="hello">
<h1>{{ message }}</h1>
<input id="sample-btn" type="button" @click="init" value="init"/>
<input id="sample-input" type="text" v-model="message" />
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import dynamicModuleSample from "@/store/dynamicModuleSample";
@Component
export default class HelloWorld extends Vue {
public init(e: Event) {
dynamicModuleSample.setMessageState("hello world");
}
public get message() {
return dynamicModuleSample.message;
}
public set message(msg: string) {
dynamicModuleSample.setMessageState(msg);
}
}
</script>
テストしたいコンポーネントが使っているVuexのモジュール
vuex-module-decorators を使っているのだが、動的モジュールとして使っている。が、単体テストをするなら静的モジュールに変更したほうがいいと思う。
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
export default new Vuex.Store({});
import { Module, VuexModule, Mutation, getModule } from 'vuex-module-decorators';
import store from "@/store"; // store/index.tsで作成したstoreをimport
@Module({
dynamic: true, // 動的モジュールとして
name: 'DynamicModuleSample',
store, // メインのstoreに紐付ける
})
class DynamicModuleSample extends VuexModule {
private messageState: string = '';
public get message() {
return this.messageState;
}
@Mutation
public setMessageState(msg: string = 'default message') {
this.messageState = msg;
}
}
export default getModule(DynamicModuleSample);
テストコード
Jestを使って書いていく。試していないが Mocha + Chai でも特に変わりないと思う。
あと、今回はコンポーネントのテストのみでモジュール自体のテストは割愛。
動的モジュールのモック化について
vuex-module-decoratorsのGithubにて以下のissuesがあるように、動的モジュールをモック化する方法は2021年現在でも解決しておらず、QiitaやStack Overflowを検索してもこれといった解決が見つからなかった。
というのも、今回のような上記のコードだとdynamicModuleSample.ts
の中でプロダクトコードのstoreに動的モジュールを紐づけていて、かつ export default getModule(DynamicModuleSample)
で公開しているため、利用するコンポーネント側で import dynamicModuleSample from "@/store/dynamicModuleSample";
するだけで使えるようになっているものの、this.$sotre
経由でVuexにアクセスしていないため、テストするときはこれが非常に厄介(shallowMount
などでlocalVueやstoreを指定しても意味がない)
仕方がないので、ここではlocalVueやstoreを使わず、プロダクトコード内のstoreから動的モジュール削除してモック用のモジュールを再登録する方法でテストコードを書くことにした。
jestのコード
import { shallowMount, Wrapper } from '@vue/test-utils';
import HelloWorld from '@/components/HelloWorld.vue';
import orgStore from "@/store"; // プロダクトコードのオリジナルstoreをインポート
import { Module, Mutation, VuexModule } from 'vuex-module-decorators';
orgStore.unregisterModule('DynamicModuleSample'); // オリジナルのstoreからオリジナルのモジュールを削除
@Module({
dynamic: true,
name: 'DynamicModuleSample',
store: orgStore, // オリジナルstoreにモック用のDummyModuleを紐づけ
})
class DummyModule extends VuexModule {
private dummy: string = "テスト";
public get message() {
return this.dummy; // ここにモックを定義すればいい
}
@Mutation
public setMessageState(msg: string) {
this.dummy = msg; // ここにモックを定義すればいい
}
}
describe('HelloWorld.vue', () => {
it('dynamic module mock test', async () => {
const wrapper: Wrapper<HelloWorld> = shallowMount(HelloWorld);
expect(wrapper.text()).toMatch('テスト');
await wrapper.find('#sample-btn').trigger('click');
expect(wrapper.text()).toMatch('hello world');
await wrapper.find('#sample-input').setValue('てすと');
expect(wrapper.text()).toMatch('てすと');
});
});
モック化するモジュール自体が簡単なものなので、この程度なら別にモックするまでもないけど、やりたいことはこれでできるはず。
ちなみに、動的じゃないモジュールだったらこうなる
ここからはおまけ。
テストのしやすさのために、プロダクトコード自体を大幅変更する手間をかけることができるなら、動的モジュールではなくstore生成時にモジュールを静的に紐付ける一般的なやり方(?)にするほうがよいだろう。
変更後のプロダクトコード
import Vue from 'vue';
import Vuex from 'vuex';
import { StaticModuleSample } from "@/store/staticModuleSample";
Vue.use(Vuex);
export default new Vuex.Store({
modules: {
StaticModuleSample,
},
});
import { Module, Mutation, VuexModule } from "vuex-module-decorators";
@Module({
name: 'StaticModuleSample',
})
export class StaticModuleSample extends VuexModule {
private countState: number = 0;
public get count() {
return this.countState;
}
@Mutation
public increment() {
this.countState++;
}
}
<template>
<div class="hello">
<h1>{{ count }}</h1>
<input id="sample2-btn" type="button" @click="increment" value="++" />
</div>
</template>
<script lang="ts">
import { StaticModuleSample } from "@/store/staticModuleSample";
import { Component, Vue } from "vue-property-decorator";
import { getModule } from "vuex-module-decorators";
@Component
export default class HelloWorld2 extends Vue {
private get staticModule() {
return getModule(StaticModuleSample, this.$store); // ここでモジュールを取得する
}
public get count() {
return this.staticModule.count;
}
public increment() {
this.staticModule.increment();
}
}
</script>
Jestのコード
shalloMount
時にlocalVueやテスト用のstoreを紐付けることができるようになった。
import { createLocalVue, shallowMount, Wrapper } from '@vue/test-utils';
import HelloWorld2 from '@/components/HelloWorld2.vue';
import { Module, Mutation, VuexModule } from 'vuex-module-decorators';
import Vuex from 'vuex';
@Module
class DummyModule extends VuexModule {
private countState: number = 100;
public get count() {
return this.countState;
}
@Mutation
public increment() {
this.countState++;
}
}
const localVue = createLocalVue();
localVue.use(Vuex);
const store = new Vuex.Store({
modules: {
StaticModuleSample: DummyModule
}
});
describe('HelloWorld2.vue', () => {
it('static module mock test', async () => {
const wrapper: Wrapper<HelloWorld2> = shallowMount(HelloWorld2, {
localVue,
store,
});
expect(wrapper.text()).toMatch('100');
await wrapper.find('#sample2-btn').trigger('click');
expect(wrapper.text()).toMatch('101');
});
});
さいごに
Vuejs + TypeScript + Vuex + Jestの単体テストコードの書き方をまとめてみた。
まとめてみるとシンプルに見えるかもしれないが、実はこのコードでテストできるようになるまでに何ヶ月もかかった。
上記でも載せたが ここのissues を見る限り、いくつかやり方があるように思うが、今回のコードが自分の中では一番書きやすい。
これ以外にもVuexをモックにするテストコードの書き方があったらぜひ教えてほしい(まぁ、プロダクトコード次第で千差万別だけど・・・)