© TOOLS BOX — Next.js / React / TypeScript コードサンプル集

サンプルガイド
←サンプル一覧
nextjsstate-management

Zustand Test の storeSync を Vitest でユニットテストする

settings から profile へ subscribe で同期するレイヤを、Vitest で「呼ばれた / 呼ばれない」「整形が正しい」を確かめるテストの書き方を解説する。

難易度: 中級·更新: 2026-05-07·
zustand

対応バージョン

nextjs 15react 19zustand 5

前提環境

Vitest または Jest の describe / it / expect の書き方を理解していること(API は同等)

概要

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) — 完成例と外部デモ

注意点

Playground 追加記事。Vitest の vi.fn() を使うが、Jest でも jest.fn() に置き換えればそのまま動く。

リポジトリを見る

関連サンプル

同じテーマや技術スタックを使った実装例

  • Zustand Test の suggestedRate を subscribeWithSelector で派生する

    settingsStore.rate から detailStore.suggestedRate を導出する派生状態の作り方を、subscribeWithSelector と shallow を軸に解説する。

  • Zustand Test の profileStore で表示用整形を 1 か所にまとめる

    settingsStore の値を trim・大文字化・フォールバックといった整形を施して保持する profileStore の最小設計を解説する。

  • Zustand Test の 4 ストア設計と storeSync を読み解く

    settings / profile / detail / ui の 4 ストアを subscribe で連携し、patchFormData・setFormData・reset を使い分ける Playground の Zustand 実装を1ステップずつ解説する。

  • Zustand Test のアクションを selector → props で安全に受け渡す

    Zustand のアクションは store 生成時から参照が安定している性質を活かし、selector で取り出して props で子に渡す書き方を解説する。

  • Zustand Test の保存処理を非同期 action と loading / error 状態で扱う

    saveSettings のような非同期処理を store のアクションに閉じ込め、status: 'idle'|'loading'|'error'|'success' を 1 つのフィールドで表現する書き方を解説する。

関連仕様

このサンプルを理解するのに役立つ仕様や概念

  • FrameworkNext.jsReact ベースのフルスタックフレームワーク。SSR・SSG・App Router・API Routes を提供する。
←サンプル一覧に戻る