コンポーネント単体テストについてつらつらと
書きます。
何をテストするのか
基本的に、考え方は関数のテストと同じだと思います。何かが入力されると結果が出力される。テストでは、その結果が正しいかを確認します。「入力」と「出力」にフォーカスすることが大切です。
一般的なヒント | Vue Test Utils では以下のように説明しています。
コンポーネントのパブリックインターフェイスを検証するテストを作成し、内部をブラックボックスとして扱うことをお勧めします。単一のテストケースでは、コンポーネントに提供された入力(ユーザーのやり取りやプロパティの変更)によって、期待される出力(結果の描画またはカスタムイベントの出力)が行われることが示されます。
このように考えれば、テストの方針も立てやすくなります。結果、何をテストしているのか理解しやすくなりますし、コードもシンプルになります。内部実装をリファクタしたとしても、テストが壊れることはないです。
なぜ書くのか
なぜテストをするのですか | Vue Test Utils から引用。
単体テストの利点:
(ここに「フレームワークへの理解が深まる」も追加できると思います。)
単体テストのすべきこと:
- 実行が早いこと
- 理解しやすいこと
- 一つの作業だけをテストすること
テストが書きにくいな思ったら、もしかしたら、コンポーネントの設計に問題があるかもしれません。例えば、あるコンポーネントに複数の役割を持たせていたり、必要以上に大きく依存関係が複雑になってたり。
テストを書くことで既存コードに改良の余地があることに気づく、そういったケースもあるかと思います。
入力と出力とは何か
具体的に「入力」と「出力」にはどのようなものがあるのでしょうか。一般的な SPA の Web アプリケーションを例にして考えてみます。
入力:
- プロパティ(親コンポーネントから渡される)
- Vue なら
props
, Angular なら@Input
に相当するもの
- Vue なら
- DOM
- ユーザーによる UI 操作により発生
- クリックアクションやフォームの入力など
- Route
- URL が変更することにより発生
- Vue なら
this.$route
, Angular ならActivatedRoute
- Store
- Flux, Redux のような状態管理を導入している場合、Store の変更もコンポーネントへの入力となる
- Vue (Vuex) なら
this.$store.state
(あるいはthis.$store.getters
), Angular (ngrx) ならthis.store.pipe(select(...)
など
出力:
- イベント(親コンポーネントへ渡す)
- Vue なら
emit
, Angular なら@Output
に相当するもの
- Vue なら
- DOM
- 表示されたテキスト, 要素の On/Off, スタイル, 属性 など
- Route
- Router へのアクション
- Store
- Vue (Vuex) なら
this.$store.{dispatch|commit}
, Angular (ngrx) ならthis.store.dispatch
など
- Vue (Vuex) なら
これ以外にもあるとは思いますが、大体このようなものではないでしょうか。
テストを書く前に調べること
初めてさわるフレームワークでコンポーネントの単体テストを書く場合、上にあげたような「入力」をシュミレートする方法と「出力」を検証する方法を知っておくと、順調にテストが書けると思います。
あとは非同期の扱いや、テストダブル(スタブ・モック・スパイなど)の扱いについて知っておくといいかと。(これらに関しては、利用するテストフレームワーク側の機能を利用することも多いです。)
一般的なコンポーネントのテストシナリオ
Angular ドキュメントには Component Test Scenarios というセクションがあり、一般的なテストシナリオの実装方法について解説しています。「一般的なテストシナリオにはどんなものがあるのか」について知るという点で、Angular ユーザー以外にも参考になると思います。
以下は抜粋です。
- Component binding
- DOM の操作 / 評価
- Component with a dependency
- 依存関係のあるコンポーネント
- Component with async service
- 非同期処理を扱うサービスを利用しているコンポーネント
- Component with inputs and outputs
- Routing component
- Router を扱うコンポーネント
- Routed components
- ルート設定されているコンポーネント
- Nested component tests
- Components with RouterLink
- Vue の場合
<nuxt-link :to="...">
,<router-link :to="...">
を使うコンポーネント
- Vue の場合
- Use a page object
- Page オブジェクトを利用して要素操作に関するロジックを分離する
Vue 単体テストのサンプル
テストシナリオを踏まえて、Vue の単体テストで必要そうなケースをいくつかピックアップしたのがこちらです。(若干中途半端ではありますが)
props
と emit
のテスト
describe("Button", () => { it(`should emit a 'click' event`, () => { const wrapper = shallowMount(Button, { propsData: { text: "text" } }); wrapper.find("button").trigger("click"); expect(wrapper.emitted().click).toBeTruthy(); }); });
nuxt-link
(router-link
) を利用しているコンポーネントのテスト
NuxtLink
(RouterLink
) をスタブします。
describe("Breadcrumbs", () => { it("should have nuxt-link correspond to props", () => { const breadcrumbs = [{ label: "Foo", path: "foo" }]; const wrapper = shallowMount(Breadcrumbs, { propsData: { breadcrumbs }, stubs: { NuxtLink: RouterLinkStub } }); expect(wrapper.find(RouterLinkStub).props().to).toBe(breadcrumbs[0].path); }); });
Router を利用しているコンポーネントのテスト
createLocalVue
を利用してグローバルの Vue クラスを汚染しないローカルの Vue クラスで VueRouter
を利用します。
import { createLocalVue, shallowMount } from "@vue/test-utils"; import VueRouter from "vue-router"; describe("Menu", () => { it(`should have '-current' class if the path is same as the current route`, () => { const localVue = createLocalVue(); localVue.use(VueRouter); const router = new VueRouter(); router.push("/foo"); const wrapper = shallowMount(Menu, { localVue, router, propsData: { menus: [ { label: "foo", value: "/foo" }, { label: "bar", value: "/bar" } ] }, stubs: { NuxtLink: RouterLinkStub } }); const fooLink = wrapper.findAll(RouterLinkStub).at(0); const barLink = wrapper.findAll(RouterLinkStub).at(1); expect(fooLink.classes()).toContain("-current"); expect(barLink.classes()).not.toContain("-current"); }); });
内部で他のコンポーネントを利用しているケース
shallowMount() を利用すると、子コンポーネントはスタブされたコンポーネントになります。
メモ:
Nuxt のページコンポーネントのテスト
Nuxt の機能と、テスト対象のコンポーネントの機能をわけて考えることが大切です。
以下のテストでは「fetch
時に適切なアクションがディスパッチされているか」と「適切な middleware
が登録されているか」についてだけテストをしています。初回アクセス時に fetch
が呼ばれているか、パラメータ変更時にも fetch
が呼ばれるか、許可されたユーザーのみアクセスできるか、などのテストはしていません。(それは Nuxt のテストや登録されているミドルウェア自体のテストになるから)
describe("FooPage", () => { it("dispatches FetchFoo before rendering the page", () => { const wrapper = shallowMount(FooPage); const store = { dispatch: jest.fn() }; const fooId = "asdf"; const route = fooRoute(fooId); const context: any = { store, route }; // simulate the nuxt fetch hook wrapper.vm.$options.fetch(context); expect(context.store.dispatch).toHaveBeenCalledWith(FetchFoo({ fooId })); }); it(`needs 'requireAuth' middleware`, () => { const wrapper = shallowMount(FooPage); expect(wrapper.vm.$options.middleware).toContain("requireAuth"); }); }); const fooRoute = fooId => ({ params: { fooId } });
ミューテーション単体のテスト
// from https://vuex.vuejs.org/ja/guide/testing.html describe("mutations", () => { it("INCREMENT", () => { // ステートのモック const state = { count: 0 }; // ミューテーションを適用する increment(state); // 結果を検証する expect(state.count).to.equal(1); }); });
アクション単体のテスト
アクションは外部の API を呼び出す場合が多いです。そのため、モック関数を作成したり、非同期処理をうまく扱う必要があります。
import { FetchFoo, ReceiveFoo, ResetShopStaffs } from "./types"; import { actions } from "../actions"; describe("actions", () => { let store; beforeEach(() => { // inject plugins store = { $api: { request: jest.fn() } }; }); it("FetchFoo request Foo and then commit ReceiveFoo", async () => { // fake data const fakeRequest = "request"; const fakeResponse = "response"; // mock functions const commit = jest.fn(); store.$api.request.mockReturnValue(fakeResponse); // dispach const action = FetchFoo(fakeRequest); actions[action.type].bind(store)({ commit }, action); // request expect(store.$api.request).toHaveBeenCalledWith(fakeRequest); await resolvePromises(); expect(commit).toHaveBeenCalledWith(ReceiveFoo(fakeResponse)); }); }); export const resolvePromises = () => new Promise(resolve => setImmediate(resolve));
ストアのテスト(アクション、ミューテーション など全てを含む)
// from https://vue-test-utils.vuejs.org/ja/guides/using-with-vuex.html import { createLocalVue } from "@vue/test-utils"; import Vuex from "vuex"; import storeConfig from "./store-config"; import { cloneDeep } from "lodash"; test("increments count value when increment is commited", () => { const localVue = createLocalVue(); localVue.use(Vuex); const store = new Vuex.Store(cloneDeep(storeConfig)); expect(store.state.count).toBe(0); store.commit("increment"); expect(store.state.count).toBe(1); }); test("updates evenOrOdd getter when increment is commited", () => { const localVue = createLocalVue(); localVue.use(Vuex); const store = new Vuex.Store(cloneDeep(storeConfig)); expect(store.getters.evenOrOdd).toBe("even"); store.commit("increment"); expect(store.getters.evenOrOdd).toBe("odd"); });
補足
グローバルプロパティをスタブする:
const $route = { path: "http://www.example-path.com" }; const wrapper = shallowMount(Foo, { mocks: { $route } });
https://vue-test-utils.vuejs.org/ja/api/options.html#mocks
まとまらない何か
テストに関係する Tips とかメモとか。
flush-promises
(setImmediate
) を利用して非同期を解決させる
Vue でグローバル登録されてるフィルタが存在する場合
import Vue from "vue"; import * as filters from "./util/filters"; // We would extract this to a function that would be reused by both app.js and jest-setup but, // we didn't want to change original production code Object.keys(filters).forEach(key => { Vue.filter(key, filters[key]); });
How do we test these globally registered filters? https://github.com/agualis/vue-hackernews-2.0/blob/master/src/jest-setup.js
同年、同月、同日にテストすることを願わん
momentjs - How do I set a mock date in Jest? - Stack Overflow
Date.now = jest.fn(() => 1487076708000); //14.02.2017`
Jest のカスタムマッチャ
const myMockFn = jest.fn()...; // The mock function was called at least once expect(mockFunc).toBeCalled();
上記のようなカスタムマッチャは .mock
プロパティを検査する方法の糖衣構文
https://jestjs.io/docs/en/mock-functions
jest.spyOn(object, methodName)
jest.fn
と同様の関数を作成する- 引数に与えられた
object[methodName]
へのコールも実装
https://jestjs.io/docs/ja/jest-object#jestspyonobject-methodname
参考 URLs
- Vue
- Jest
- Angular
- その他
- テストダブル - Wikipedia
- テストスタブ, テストスパイ, モックオブジェクト, フェイクオブジェクト, ダミーオブジェクト
- テストダブル - Wikipedia