Vuex4のモックが楽だった話

小一時間、どうすればモック出来るんこれ？と悩んだものの結果的に簡単にモックできたのでメモ
最終的なテストコードは真ん中あたりにあります。

## 対象のモジュール

- サンプルはローディング状態を管理するだけの簡単なモジュール
- actions/gettersのTYPOに悩まされたくないので、定数管理する都合上namespacedは切っていません
    - 呼び出し時にprefixを付けるか、定義時にprefixを取り除くかする必要があるため
- PATHの解決に@を使うのが好みではないため、相対PATHにしています

```typescript
import { ActionContext, Module, MutationPayload, Payload } from 'vuex';
import { RootState } from '../..';

const MODULE_NAME = 'ui/loading';

export type State = {
  loading: boolean;
};

// mutators
export const SET_LOADING = `${MODULE_NAME}/setState` as const;

// actions
export const LOADING_ACTION = `${MODULE_NAME}/loading` as const;
export const LOADED_ACTION = `${MODULE_NAME}/loaded` as const;

// getters
export const GET_LOADING = `${MODULE_NAME}/getLoading` as const;

// mutator payloads
export interface SetLoadingPayload extends MutationPayload {
  type: typeof SET_LOADING;
  payload: boolean;
}

// action payloads
export interface LoadingActionPayload extends Payload {
  type: typeof LOADING_ACTION;
}
export interface LoadedActionPayload extends Payload {
  type: typeof LOADED_ACTION;
}

export const LoadingModule: Module<State, RootState> = {
  state: {
    loading: false,
  },
  mutations: {
    [SET_LOADING](state: State, { payload }: SetLoadingPayload): void {
      state.loading = payload;
    },
  },
  actions: {
    [LOADING_ACTION]({ commit }: ActionContext<State, RootState>): void {
      commit<SetLoadingPayload>({ type: SET_LOADING, payload: true });
    },
    [LOADED_ACTION]({ commit }: ActionContext<State, RootState>): void {
      commit<SetLoadingPayload>({ type: SET_LOADING, payload: false });
    },
  },
  getters: {
    [GET_LOADING]: (state: State): boolean => state.loading,
  },
};

export default LoadingModule;
```

## 本題

最初はstoreからactionsを取り出す方法とか模索していたが、そんな必要なかった。
結論から言うと、`new Vuex.store`する際にモックした関数を食わせるだけで十分だった。

## やりたかったこと

- 意図した通りのmutation/action/getterが呼び出されていることを確認したい
- 意図した通りのpayloadが渡されていることを確認したい
- 第一引数はActionContextなので、ここでは`Expect.anything()`を指定しておく
- `new Vuex.Store`する際に定義時のRootStateが参照されるせいかジェネリック型に出来なかったのは残念
- 内部で分岐が発生するようなケースがあれば、storeを作り直すだけでOK

## テストコード

```typescript
import Vuex from 'vuex';
import Module, * as LoadingModule from '../loading';

describe('modules/ui/loading', () => {
  const setLoading = jest.fn();
  const loadingAction = jest.fn();
  const loadedAction = jest.fn();
  const getLoading = jest.fn();

  const store = new Vuex.Store({
    modules: {
      loading: {
        ...Module,
        actions: {
          ...Module.actions,
          [LoadingModule.LOADING_ACTION]: loadingAction,
          [LoadingModule.LOADED_ACTION]: loadedAction,
        },
        mutations: {
          ...Module.mutations,
          [LoadingModule.SET_LOADING]: setLoading,
        },
        getters: {
          ...Module.getters,
          [LoadingModule.GET_LOADING]: getLoading,
        },
      },
    },
  });
  const { dispatch, commit, getters } = store;

  describe('INITIAL STATE', () => {
    it('loading', () => {
      expect(store.state).toEqual({ loading: { loading: false } });
    });
  });

  describe('mutations', () => {
    it('SET_LOADING', () => {
      commit<LoadingModule.SetLoadingPayload>({ type: LoadingModule.SET_LOADING, payload: true });
      expect(setLoading).toHaveBeenCalledWith(expect.anything(), { type: LoadingModule.SET_LOADING, payload: true });
    });
  });

  describe('actions', () => {
    it('LOADING_ACTION_PAYLOAD', async () => {
      await dispatch<LoadingModule.LoadingActionPayload>({ type: LoadingModule.LOADING_ACTION });
      expect(loadingAction).toHaveBeenCalledWith(expect.anything(), { type: LoadingModule.LOADING_ACTION });
    });

    it('LOADED_ACTION_PAYLOAD', async () => {
      await dispatch<LoadingModule.LoadedActionPayload>({ type: LoadingModule.LOADED_ACTION });
      expect(loadedAction).toHaveBeenCalledWith(expect.anything(), { type: LoadingModule.LOADED_ACTION });
    });
  });

  describe('getters', () => {
    it('GET_LOADING', () => {
      getters[LoadingModule.GET_LOADING];
      expect(getLoading).toHaveBeenCalledTimes(1);
    });
  });
});
```

## Vue3/Vuex4の所感

- Piniaとかも出てるけど、Vue3/Vuex4でTypeScript対応はかなり楽に出来るなという印象
    - Vuexの方では特に困った覚えはない
- 最初の方でも書いたが`namespaced: true`を指定すると定数管理してTYPOを防ごうという目論見が見事に崩れる
- dispatchにPayloadの型を指定するとtypeを含んだ`PayloadWithType`になってしまい、取り回しが悪くなってしまうのがとても残念
- TypeScriptで書く上で非常に困ってるのが、`script setup`記法だとコンポーネントをジェネリック型に出来ない点
    - 解決策としてslotを使うという手段をとっているが、スマートじゃない
    - [Githubにdiscussionsがある](https://github.com/vuejs/rfcs/discussions/436)ので、そのうち実現しそうではある
    - そのためだけに`defineComponent`使うのもなんか負けた気がする

## ちなみにPiniaで書くとこうなる

### 対象ファイル

```typescript
import { defineStore } from 'pinia';

type State = {
  loading: boolean;
};

export const uiLoadingStore = defineStore('ui/loading', {
  state: (): State => ({
    loading: false,
  }),
  actions: {
    start(): void {
      this.loading = true;
    },
    finish(): void {
      this.loading = false;
    },
  },
});
```

### テスト

```typescript
import { setActivePinia, createPinia } from 'pinia';
import { uiLoadingStore } from '../loading';

describe('ui/loading', () => {
  beforeEach(() => {
    setActivePinia(createPinia());
  });

  it('initial state', () => {
    const uiLoading = uiLoadingStore();
    expect(uiLoading.$state).toEqual({ loading: false });
  });

  describe('action', () => {
    it('start', () => {
      const uiLoading = uiLoadingStore();
      jest.spyOn(uiLoading, 'start');

      uiLoading.start();
      expect(uiLoading.start).toHaveBeenCalledWith();
    });

    it('finish', () => {
      const uiLoading = uiLoadingStore();
      jest.spyOn(uiLoading, 'finish');

      uiLoading.finish();
      expect(uiLoading.finish).toHaveBeenCalledWith();
    });
  });
});
```