Testing Tauri Zustand Stores with Vitest: Mock IPC and Reset Singletons
Tauri apps often put frontend state in Zustand stores and call Rust through invoke(). Testing that layer is valuable because it is where UI state, IPC responses, localStorage, timers, and error handling meet.
The traps are predictable:
- The test environment needs browser APIs.
- Tauri IPC does not exist in jsdom.
- Zustand stores are module singletons.
- State leaks across tests unless you reset it deliberately.
- Dual TypeScript/Rust logic can drift unless you test parity.
Use jsdom for browser-backed stores
If a store touches localStorage, window, timers, or DOM-adjacent APIs, configure Vitest with jsdom:
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
env: {
NODE_ENV: "test",
},
},
});
The explicit NODE_ENV value prevents a polluted shell environment from accidentally running React or related tools in production mode during tests.
Mock Tauri IPC at the global boundary
Tauri's frontend invoke path depends on runtime globals that are absent in jsdom. Stub them before importing code that calls invoke():
import { beforeEach, vi } from "vitest";
beforeEach(() => {
vi.stubGlobal("__TAURI_INTERNALS__", {
invoke: vi.fn(),
});
});
Then test the store behavior:
import { expect, test, vi } from "vitest";
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" },
]);
});
Reset module singletons between tests
Zustand stores are usually created at module scope:
export const useProjectStore = create<ProjectState>()(...)
That means a static import gives every test the same store instance.
Use vi.resetModules() and dynamic imports when you need a fresh store:
import { beforeEach, test, expect, vi } from "vitest";
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
});
test("starts empty", async () => {
const { useProjectStore } = await import("./projectStore");
expect(useProjectStore.getState().projects).toEqual([]);
});
test("does not inherit previous test state", async () => {
const { useProjectStore } = await import("./projectStore");
expect(useProjectStore.getState().projects).toEqual([]);
});
If your project has a shared reset helper, that is fine too. The key is that each test must start from a known state.
Test side effects across stores deliberately
If one store updates another, assert that side effect explicitly:
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");
});
Cross-store behavior is where regressions hide because each store can look correct in isolation.
Use fake timers for trial and polling logic
Stores with timers should not depend on wall-clock time:
import { beforeEach, afterEach, vi } from "vitest";
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
Use this for trial states, retry backoff, debounce logic, and periodic flushes.
Keep TypeScript and Rust algorithms in parity
If the frontend has a TypeScript implementation of logic that also exists in Rust, port the same test cases to both sides.
Good candidates:
- frame delimiter detection
- checksum formatting
- byte parsing
- path normalization
- protocol field validation
The goal is not just coverage. It is drift detection. If Rust and TypeScript both parse the same input, they should agree on edge cases.
References
Summary
Testing Tauri Zustand stores works well when you treat IPC and module state as explicit boundaries. Use jsdom, mock Tauri globals before import, reset singleton stores between tests, and port shared algorithm cases across TypeScript and Rust.