概要
Zustand Test は 入力 → 同期 → 保存 → リセット が画面の中で連鎖します。ユニットテスト (storeSync テスト編) でロジックは個別に押さえられても、「ブラウザ上で実際にこの流れが成立しているか」は別の関心事です。
Playwright で押さえるのは次の 3 点。
- 入力 → Monitor の表示:
watch経由で settings に値が入り、Monitor の<pre>に出る - 保存 → 確定値: Zod を通った値が settings の確定領域に書かれる
- リセット → 初期値: フォーム / 各 store / Monitor が初期表示に戻る
インストール
npm install -D @playwright/test
npx playwright install
window 経由で store にアクセスできるようにする
Playwright のテストから store の中身を直接 assert するには、開発時だけ window に store を expose しておくのが手早いです。本番ビルドには出さないよう環境変数でガードします。
// src/components/SyncBoot.tsx
"use client";
import { useEffect } from "react";
import { useSettingsStore } from "@/store/settingsStore";
export function SyncBoot() {
useEffect(() => {
if (process.env.NEXT_PUBLIC_E2E === "1") {
// テスト用に store を expose
(window as unknown as { __settings: typeof useSettingsStore }).__settings = useSettingsStore;
}
}, []);
return null;
}
NEXT_PUBLIC_E2E=1 npm run dev でテスト実行用に立ち上げる、という使い方です。
基本のスペック構造
tests/zustand-demo.spec.ts に 1 ファイルでシナリオをまとめます。beforeEach で開き直すと、毎テスト初期状態で始まります。
// tests/zustand-demo.spec.ts
import { test, expect } from "@playwright/test";
test.beforeEach(async ({ page }) => {
await page.goto("/zustand-demo");
// 全ストアをリセット
await page.getByRole("button", { name: "全ストアをリセット" }).click();
});
test.describe("Zustand Test", () => {
/* ...各 test がここに並ぶ */
});
page.goto の URL は playwright.config.ts の baseURL を設定すれば相対で書けます。Playground は /zustand-demo を想定。
入力 → Monitor の同期を確認する
getByRole / getByLabel で要素を引き、fill で入力します。Monitor 側の <pre> に書き込まれた JSON を toContainText で検証します。
test("名前を入力すると settingsStore Monitor に反映される", async ({ page }) => {
await page.getByLabel("名前").fill("alice");
const monitor = page.getByRole("region", { name: /settingsStore/i });
await expect(monitor).toContainText('"name": "alice"');
});
Monitor を <section aria-label="settingsStore"> のように アクセシブルな名前 で書いておくと、getByRole("region", { name: "..." }) で安定して引けます。
window 経由で store の値を assert する
DOM を経由せず store を直接見たい場合は、expose しておいた __settings を使います。
test("保存ボタンで Zod を通った値が確定する", async ({ page }) => {
await page.getByLabel("名前").fill("alice");
await page.getByLabel("メール").fill("alice@example.com");
await page.getByLabel("レート").fill("80");
await page.getByRole("button", { name: "保存" }).click();
const snapshot = await page.evaluate(() => {
const s = (window as any).__settings.getState();
return { name: s.name, email: s.email, rate: s.rate };
});
expect(snapshot).toEqual({ name: "alice", email: "alice@example.com", rate: 80 });
});
page.evaluate の中はブラウザ実行コンテキストなので、useSettingsStore を直接 import せずに window.__settings 経由で参照します。
エラー UI のテスト
role="alert" が付いていれば getByRole("alert") で引けます。Zod の検証に引っかかる値を入れて、メッセージが現れることを確認します。
test("メールが不正だとエラーメッセージが表示される", async ({ page }) => {
await page.getByLabel("名前").fill("alice");
await page.getByLabel("メール").fill("not-an-email");
await page.getByRole("button", { name: "保存" }).click();
await expect(page.getByText("有効なメールアドレスを入力してください")).toBeVisible();
});
エラー UI / aria 編 の通りに aria-describedby を付けていれば、メッセージは関連付き テキストとして可視 になります。
タブ切り替えのキーボード操作テスト
TabBar a11y 編 の矢印キー操作も Playwright で再現できます。
test("矢印キーで詳細タブに移動できる", async ({ page }) => {
await page.getByRole("tab", { name: "フォーム" }).focus();
await page.keyboard.press("ArrowRight");
await expect(page.getByRole("tab", { name: "詳細" })).toBeFocused();
await expect(page.getByRole("tab", { name: "詳細" })).toHaveAttribute("aria-selected", "true");
});
toBeFocused / toHaveAttribute("aria-selected", "true") の 2 軸で押さえると、a11y の挙動とアプリの状態の両方が検証できます。
リセット動作のテスト
「全ストアをリセット」が UI / store 両方を初期値に戻すことを確認します。
test("全ストアをリセットでフォームと store が初期値に戻る", async ({ page }) => {
await page.getByLabel("名前").fill("alice");
await page.getByLabel("レート").fill("80");
await page.getByRole("button", { name: "保存" }).click();
await page.getByRole("button", { name: "全ストアをリセット" }).click();
// DOM 側
await expect(page.getByLabel("名前")).toHaveValue("");
await expect(page.getByLabel("レート")).toHaveValue("0");
// store 側
const snapshot = await page.evaluate(() => (window as any).__settings.getState());
expect(snapshot.name).toBe("");
expect(snapshot.rate).toBe(0);
});
DOM と store の 両方を assert することで、片方だけ戻っているような半端な状態を検出できます。
ポイント
- store を
window.__settingsで expose すると、テストからgetState()を直接見られる。本番には出さないよう環境変数でガードする getByRole/getByLabelを中心に locator を組む。a11y を満たすほど locator が短く書ける- DOM の検証は
toBeVisible/toContainText/toHaveAttribute、store の検証はpage.evaluateでgetState()を読む - 「全ストアをリセット」のような 横断機能 は DOM と store の 両方 を assert する。半端な状態を検出できる
- a11y 関連(タブ・エラー)も Playwright でカバー。
toBeFocused/aria-selectedで属性まで含めて検証する
関連記事
セットで読むと文脈がつながる関連記事は次のとおりです。
- storeSync テスト編 — ロジック単位の Vitest テスト
- TabBar a11y 編 — Playwright で再現する操作の仕様
- エラー UI / aria 編 —
getByRole("alert")で引けるエラー表示 - Zustand Test (Build) — 完成例と外部デモ