Vue.js+Vuexで作成したカウンターアプリのユニットテストを作成する

## 概要
Vue.js+Vuexを利用したカウンターアプリのテストクラスを作成します。テストツールは`Jest`を使用。

この記事では、カウンターアプリのテストとして適切であるかという点よりも、Vue.js+Vuexのテストのサンプルとなるように意識して書いています。
これからテストを書こうと思っている方の参考になれば幸いです。

## テスト対象
今回のテストの対象となるコンポーネントとカウンターモジュールです。

### Counterコンポーネント

カウントされる数値と`increment!`と `decrement!`ボタンが表示されています。

```vuejs:Counter.vue
<template>
  <div>
    <p>{{ counter }}</p>
    <button @click="increment">increment!</button>
    <button @click="decrement">decrement!</button>
  </div>
</template>

<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import { Getter, Action, namespace } from "vuex-class";
import { CounterState } from "../types/counter";

const CounterGetter = namespace("CounterModule", Getter);
const CounterAction = namespace("CounterModule", Action);

@Component
export default class Counter extends Vue {
  @CounterGetter
  counter!: CounterState["counter"];

  @CounterAction
  increment!: () => void;
  @CounterAction
  decrement!: () => void;
}
</script>
```

### Counterモジュール
VuexStoreをモジュールに分割し、CounterModuleを作成しました。
Actionは単にMutationをcommitするだけのものとなっていますが、Vuexのデータフローをわかりやすくするために、このような形にしています。


```typescript:counter.ts
import Vue from "vue";
import Vuex, { GetterTree, MutationTree, ActionTree, Module } from "vuex";
import { CounterState } from "@/types/counter";
import { RootState } from "@/store";

Vue.use(Vuex);

const state: CounterState = {
  counter: 0
};

const getters: GetterTree<CounterState, RootState> = {
  counter: (state): CounterState["counter"] => {
    return state.counter;
  }
};

const mutations: MutationTree<CounterState> = {
  increment: state => {
    state.counter += 1;
  },
  decrement: state => {
    state.counter -= 1;
  }
};

const actions: ActionTree<CounterState, RootState> = {
  increment: ({ commit }) => {
    commit("increment");
  },
  decrement: ({ commit }) => {
    commit("decrement");
  }
};

export const CounterModule: Module<CounterState, RootState> = {
  namespaced: true,
  state,
  mutations,
  actions,
  getters
};
```

## テスト観点
- VuexStore (CounterModule)
- コンポーネント (Counterコンポーネント)

VuexStoreのテストは、getterやmutation、actionのロジック単体を検証します。

コンポーネントのテストは、コンポーネントがVuexStoreのstateの状態に基づいて正しく動作していてることを検証します。VuexStoreのmutationやactionの内部ロジックについては、VuexStoreのテストで検証されるので、コンポーネントのテストでは検証しません。

## VuexStore(CounterModule)のテスト
まず、`tests/unit/CounterModule.spec.ts`を作成します。
拡張子に`.spec.js`をつけることで、Jestでテストが実行されます。

以下についてテストを行います。

- Getter
- Mutation
- Action


```ts:CounterModule.spec.ts
import { CounterState } from "@/types/counter";
import { CounterModule } from "@/store/modules/counter";

describe("CounterModule", () => {
  describe("getters", () => {
    it("should be able to get counter", () => {
      const state: CounterState = {
        counter: 1
      };

      const wrapper = (getters: any) => getters.counter(state);
      const counter: CounterState = wrapper(CounterModule.getters);

      expect(counter).toEqual(state.counter);
    });
  });

  describe("mutations", () => {
    let state: CounterState;

    beforeEach(() => {
      state = {
        counter: 1
      };
    });

    it("should be able to increment counter", () => {
      const wrapper = (mutations: any) => mutations.increment(state);
      wrapper(CounterModule.mutations);

      expect(state.counter).toEqual(2);
    });

    it("should be able to decrement counter", () => {
      const wrapper = (mutations: any) => mutations.decrement(state);
      wrapper(CounterModule.mutations);

      expect(state.counter).toEqual(0);
    });
  });

  describe("actions", () => {
    let state: CounterState;

    beforeEach(() => {
      state = {
        counter: 1
      };
    });

    it("should be able to commit increment", () => {
      const commit = jest.fn();

      const wrapper = (actions: any) => actions.increment({ commit });
      wrapper(CounterModule.actions);

      expect(commit).toHaveBeenCalledWith("increment");
    });

    it("should be able to commit decrement", () => {
      const commit = jest.fn();

      const wrapper = (actions: any) => actions.decrement({ commit });
      wrapper(CounterModule.actions);

      expect(commit).toHaveBeenCalledWith("decrement");
    });
  });
});

```
#### Getter
getterで取得した値がstateと一致していることを検証。

補足：getter、mutation、actionのテストに共通しているのですが、
以下のコードは、TypeScriptのエラーを回避するため、強引にany型にキャストしています。

```typescript
const wrapper = (getters: any) => getters.counter(state);
const counter: CounterState = wrapper(CounterModule.getters);
```

以下は、テストは通過しますが、TypeScriptでエラーが表示されます。

```typescript
CounterModule.actions.increment({ commit });
```

#### Mutation
`beforeEach()`でstateを初期化し、mutationを実行することでstateの値が変更されていることを検証。

#### Action
Storeのcommitを`jest.fn()`でモック化しています。
`expect(commit).toHaveBeenCalledWith("increment");`でactionがmutationをコミットしていることを検証。


## コンポーネントのテスト
ここでは、以下の3点をテストを行います。

- カウントの数値の表示
- incrementボタン押下の際のActionの呼び出し
- decrementボタン押下の際のActionの呼び出し

ライブラリには`vue-test-utils`を使用。

```ts:Cunter.spec.ts
import { shallowMount, createLocalVue } from "@vue/test-utils";
import Vuex from "vuex";
import Counter from "@/components/Counter.vue";
import { CounterModule } from "@/store/modules/counter";
import { CounterState } from "@/types/counter";

const localVue = createLocalVue();

localVue.use(Vuex);

describe("Counter.vue", () => {
  let store: any;
  let state: CounterState;
  let actions: any;

  beforeEach(() => {
    state = {
      counter: 1
    };

    actions = {
      increment: jest.fn(),
      decrement: jest.fn()
    };

    store = new Vuex.Store({
      modules: {
        CounterModule: {
          namespaced: true,
          state,
          actions,
          getters: CounterModule.getters
        }
      }
    });
  });

  it('renders "state.counter" in first p tag', () => {
    const wrapper = shallowMount(Counter, { store, localVue });
    const p = wrapper.find("p");
    expect(p.text()).toBe(state.counter.toString());
  });

  it('calls store action "increment" when increment button is clicked', () => {
    const wrapper = shallowMount(Counter, { store, localVue });
    const button = wrapper.findAll("button").at(0);
    button.trigger("click");
    expect(actions.increment).toHaveBeenCalled();
  });

  it('calls store action "decrement" when decrement button is clicked', () => {
    const wrapper = shallowMount(Counter, { store, localVue });
    const button = wrapper.findAll("button").at(1);

    button.trigger("click");
    expect(actions.decrement).toHaveBeenCalled();
  });
});

```
#### テストのポイント
VuexStoreのモジュールのテストを可能にするため、CounterModuleを作成しています。
ActionはJestのモック関数`jest.fn()`を使用し、ボタンが押下された際に、`actions.increment`または`actions.decrement`が呼び出されたことを検証しています。

## 参考記事
以下の記事を参考にしています。
[Vue Test Utils](https://vue-test-utils.vuejs.org/ja/)
[Vue testing handbook](https://lmiller1990.github.io/vue-testing-handbook/)