小一時間、どうすればモック出来るんこれ?と悩んだものの結果的に簡単にモックできたのでメモ
最終的なテストコードは真ん中あたりにあります。
対象のモジュール
- サンプルはローディング状態を管理するだけの簡単なモジュール
- actions/gettersのTYPOに悩まされたくないので、定数管理する都合上namespacedは切っていません
- 呼び出し時にprefixを付けるか、定義時にprefixを取り除くかする必要があるため
- PATHの解決に@を使うのが好みではないため、相対PATHにしています
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
テストコード
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があるので、そのうち実現しそうではある
- そのためだけに
defineComponent
使うのもなんか負けた気がする
ちなみにPiniaで書くとこうなる
対象ファイル
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;
},
},
});
テスト
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();
});
});
});