VitestでTauri Zustand storeをテストする: IPC mockとsingleton reset
Tauriアプリでは、frontend stateをZustand storeに置き、Rustを invoke() で呼ぶことがよくあります。この層は、UI state、IPC response、localStorage、timer、error handlingが交差するため、テスト価値が高いです。
罠はだいたい決まっています。
- test environmentにbrowser APIが必要
- jsdomにはTauri IPCがない
- Zustand storeはmodule singleton
- resetしないとstateがtest間で漏れる
- TypeScriptとRustのlogicがdriftする
browser-backed storeにはjsdomを使う
storeが localStorage、window、timer、DOM周辺APIを触るなら、Vitestをjsdomにします。
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
env: {
NODE_ENV: "test",
},
},
});
NODE_ENV を明示しておくと、shell環境汚染でReactや関連toolがproduction modeになる事故を避けやすくなります。
Tauri IPCはglobal境界でmockする
Tauri frontendのinvoke pathは、jsdomにないruntime globalに依存します。invoke() を呼ぶcodeをimportする前にstubします。
import { beforeEach, vi } from "vitest";
beforeEach(() => {
vi.stubGlobal("__TAURI_INTERNALS__", {
invoke: vi.fn(),
});
});
store behaviorをtestします。
test("loads projects from Tauri", async () => {
const invoke = vi.fn().mockResolvedValue([
{ id: "p1", name: "Example" },
]);
vi.stubGlobal("__TAURI_INTERNALS__", { invoke });
const { useProjectStore } = await import("./projectStore");
await useProjectStore.getState().loadProjects();
expect(invoke).toHaveBeenCalledWith("load_projects", {});
expect(useProjectStore.getState().projects).toEqual([
{ id: "p1", name: "Example" },
]);
});
module singletonをtestごとにresetする
Zustand storeは通常module scopeで作られます。
export const useProjectStore = create<ProjectState>()(...)
static importすると、すべてのtestが同じstore instanceを共有します。
fresh storeが必要なら、vi.resetModules() とdynamic importを使います。
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
});
test("starts empty", async () => {
const { useProjectStore } = await import("./projectStore");
expect(useProjectStore.getState().projects).toEqual([]);
});
共有reset helperがあるならそれでも構いません。重要なのは、各testが既知のstateから始まることです。
store間のside effectを明示的にtestする
あるstoreが別storeを更新するなら、その副作用をassertします。
test("appendBatch updates frame detection", async () => {
const { useLogStore } = await import("./logStore");
const { useFrameStore } = await import("./frameStore");
useLogStore.getState().appendBatch(["01 02 03"]);
expect(useFrameStore.getState().lastFrame).toEqual("01 02 03");
});
store単体では正しく見えても、cross-store behaviorでregressionが隠れます。
trialやpolling logicにはfake timerを使う
timerを持つstoreはwall-clock timeに依存させません。
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
trial state、retry backoff、debounce、periodic flushで使います。
TypeScriptとRust algorithmのparityを保つ
frontendにもRustにも同じlogicがあるなら、同じtest caseを両方へ移植します。
対象になりやすいものです。
- frame delimiter detection
- checksum formatting
- byte parsing
- path normalization
- protocol field validation
目的はcoverageだけではありません。drift detectionです。