コンポーネント単体テストについてつらつらと

書きます。

何をテストするのか

f:id:ryotah:20181203134621p:plain

コンポーネント単体テストで何をテストするのか。

基本的に、考え方は関数のテストと同じだと思います。何かが入力されると結果が出力される。テストでは、その結果が正しいかを確認します。「入力」と「出力」にフォーカスすることが大切です。

一般的なヒント | Vue Test Utils では以下のように説明しています。

コンポーネントのパブリックインターフェイスを検証するテストを作成し、内部をブラックボックスとして扱うことをお勧めします。単一のテストケースでは、コンポーネントに提供された入力(ユーザーのやり取りやプロパティの変更)によって、期待される出力(結果の描画またはカスタムイベントの出力)が行われることが示されます。

このように考えれば、テストの方針も立てやすくなります。結果、何をテストしているのか理解しやすくなりますし、コードもシンプルになります。内部実装をリファクタしたとしても、テストが壊れることはないです。

なぜ書くのか

なぜテストをするのですか | Vue Test Utils から引用。

単体テストの利点:

  • コンポーネントがどう動作すべきかのドキュメントを提供します
  • 過度な手動テストの時間を節約します
  • 新しい機能におけるバグを減らします
  • 設計を改良します
  • リファクタリングを容易にします

(ここに「フレームワークへの理解が深まる」も追加できると思います。)

単体テストのすべきこと:

  • 実行が早いこと
  • 理解しやすいこと
  • 一つの作業だけをテストすること

テストが書きにくいな思ったら、もしかしたら、コンポーネントの設計に問題があるかもしれません。例えば、あるコンポーネントに複数の役割を持たせていたり、必要以上に大きく依存関係が複雑になってたり。

テストを書くことで既存コードに改良の余地があることに気づく、そういったケースもあるかと思います。

入力と出力とは何か

f:id:ryotah:20181203134642p:plain

具体的に「入力」と「出力」にはどのようなものがあるのでしょうか。一般的な SPA の Web アプリケーションを例にして考えてみます。

入力:

  • プロパティ(親コンポーネントから渡される)
    • Vue なら props, Angular なら @Input に相当するもの
  • 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 に相当するもの
  • DOM
    • 表示されたテキスト, 要素の On/Off, スタイル, 属性 など
  • Route
    • Router へのアクション
  • Store
    • Vue (Vuex) なら this.$store.{dispatch|commit}, Angular (ngrx) なら this.store.dispatch など

これ以外にもあるとは思いますが、大体このようなものではないでしょうか。

テストを書く前に調べること

初めてさわるフレームワークコンポーネント単体テストを書く場合、上にあげたような「入力」をシュミレートする方法と「出力」を検証する方法を知っておくと、順調にテストが書けると思います。

あとは非同期の扱いや、テストダブル(スタブ・モック・スパイなど)の扱いについて知っておくといいかと。(これらに関しては、利用するテストフレームワーク側の機能を利用することも多いです。)

一般的なコンポーネントのテストシナリオ

Angular ドキュメントには Component Test Scenarios というセクションがあり、一般的なテストシナリオの実装方法について解説しています。「一般的なテストシナリオにはどんなものがあるのか」について知るという点で、Angular ユーザー以外にも参考になると思います。

以下は抜粋です。

Vue 単体テストのサンプル

テストシナリオを踏まえて、Vue の単体テストで必要そうなケースをいくつかピックアップしたのがこちらです。(若干中途半端ではありますが)

propsemit のテスト

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();
  });
});

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