概要
Zustand Test の storeSync は、settingsStore の変化を起点に profileStore / detailStore を更新するレイヤです。
ここをユニットテストすると、次の 3 つを「明文化された仕様」として固定できます。
- 正しい同期が起きること: settings の変化 → profile の
updateFromが期待値で呼ばれる - 不要な同期が起きないこと: 関係ないフィールドの変化では発火しない(
shallowの効果) - 整形ルールが意図どおりに走ること: profile に書き込まれた値が整形済みである
本記事は Vitest を前提に書いていますが、Jest でも vi を jest に置き換えるだけでそのまま動きます。
インストール
npm install -D vitest @vitest/ui jsdom
vitest.config.ts に environment: "jsdom" を設定しておきます(Zustand 自体は jsdom 不要ですが、React コンポーネントのテストを混在させるなら必要)。
テスト前に store をリセットする
Zustand の store は モジュールスコープのシングルトン です。テスト同士で状態が漏れないように、beforeEach で必ず reset を呼びます。
// src/store/__tests__/storeSync.test.ts
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useSettingsStore } from "../settingsStore";
import { useProfileStore } from "../profileStore";
import { useDetailStore } from "../detailStore";
import { setupStoreSync } from "../storeSync";
beforeEach(() => {
useSettingsStore.getState().reset();
useProfileStore.getState().reset();
useDetailStore.getState().reset();
});
各 store に reset action を持たせている Zustand 編 の設計が、ここで効いてきます。
settings → profile の同期をテストする
setupStoreSync を呼んで購読を有効にし、settingsStore.setFormData で値を確定書き込みします。profileStore.displayName が整形済みで入っていることを確認します。
describe("storeSync: settings → profile", () => {
it("name と email の変化で profileStore が更新される", () => {
const unsubscribe = setupStoreSync();
useSettingsStore.getState().setFormData({
name: " alice ",
email: "Alice@example.com",
rate: 10,
});
const profile = useProfileStore.getState();
expect(profile.displayName).toBe("ALICE"); // trim + uppercase
expect(profile.emailLabel).toBe("alice@example.com"); // trim + lowercase
unsubscribe();
});
});
ポイントは2つです。
setupStoreSync()の戻り値のunsubscribeを テスト末尾で呼ぶ。次のテストに購読が漏れない- 整形ルール(trim・大文字化)の挙動を assertion で固定 する。仕様が文書化される
関係ないフィールドでは発火しないことをテストする
subscribeWithSelector + shallow のおかげで、rate だけ変わったときに profile 側の updateFrom は呼ばれない はずです。これを vi.spyOn でカウントします。
it("rate の変化では profile の updateFrom が呼ばれない", () => {
const unsubscribe = setupStoreSync();
const spy = vi.spyOn(useProfileStore.getState(), "updateFrom");
useSettingsStore.getState().patchFormData({ rate: 50 });
expect(spy).not.toHaveBeenCalled();
unsubscribe();
spy.mockRestore();
});
スパイを 同期セットアップ後に張る のが要点です。先に張ると最初の同期で 1 回呼ばれてしまうことがあります(store 設計次第ですが、安全側に倒すなら毎回この順序)。
subscribeWithSelector の selector が呼ばれるタイミング
subscribe を 生で書いた(storeSync を経由しない)テストも 1 本書いておくと、middleware の挙動を理解するのに役立ちます。
it("selector が返す値が変わったときだけ listener が呼ばれる", () => {
const listener = vi.fn();
const unsub = useSettingsStore.subscribe(
(s) => s.name,
listener
);
useSettingsStore.getState().patchFormData({ rate: 99 });
expect(listener).not.toHaveBeenCalled();
useSettingsStore.getState().patchFormData({ name: "bob" });
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith("bob", "");
unsub();
});
listener の引数は (currentValue, previousValue) です。second argument が前の state ではなく、selector の 前の戻り値 である点に注意します。
整形関数のテストは store から切り離す
profileStore 編 で書いたとおり、整形関数は store の外に出しています。整形関数の単体テストは store に依存させずに書けます。
// src/store/__tests__/profileFormatters.test.ts
import { describe, expect, it } from "vitest";
import { formatDisplayName, formatEmailLabel } from "../profileFormatters";
describe("formatDisplayName", () => {
it("trim して大文字化する", () => {
expect(formatDisplayName(" alice ")).toBe("ALICE");
});
it("空文字はフォールバック表示", () => {
expect(formatDisplayName("")).toBe("(未設定)");
});
});
describe("formatEmailLabel", () => {
it("trim して小文字化する", () => {
expect(formatEmailLabel(" Alice@Example.com ")).toBe("alice@example.com");
});
});
整形関数は 純粋関数 なので、Zustand のセットアップを一切呼ばずにテストできます。store のテストよりも軽量で速いので、CI のフィードバックも早くなります。
ポイント
- store はモジュールスコープのシングルトン。
beforeEachでresetを呼んで 状態漏れを防ぐ setupStoreSyncの 戻り値unsubscribeをテスト末尾で必ず呼ぶ。購読が次のテストに残らない- 「呼ばれない」アサーションは
vi.spyOn(...).not.toHaveBeenCalled()を使う。shallowの効果を可視化できる - subscribe の listener の引数は
(current, previous)。previous は前の state ではなく selector の前の戻り値 - 整形関数は store の外に切り出して 純粋関数として テストする。Zustand のセットアップなしで速く回る
関連記事
セットで読むと文脈がつながる関連記事は次のとおりです。
- Zustand 編 —
setupStoreSyncの本体実装 - profileStore 編 — 整形関数を store の外に切り出す書き方
- 派生状態編 —
subscribeWithSelector+shallowの挙動 - Zustand Test (Build) — 完成例と外部デモ