← ./articles-ja

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が localStoragewindow、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です。

参考