概要
@storybook/test の userEvent と expect を play 関数に記述することで、Story 上でユーザー操作をシミュレートしてアサーションを実行するインタラクションテストを書く。storybook-component-story の基本ストーリー定義に、動作検証を追加するパターン。
インストール
npm install --save-dev storybook @storybook/react @storybook/test @storybook/addon-interactions
実装
テスト対象コンポーネント
// components/LoginForm.tsx
"use client";
import { useState } from "react";
type Props = {
onLogin: (email: string, password: string) => Promise<void>;
};
export function LoginForm({ onLogin }: Props) {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
setLoading(true);
setError(null);
try {
await onLogin(fd.get("email") as string, fd.get("password") as string);
} catch (err) {
setError(err instanceof Error ? err.message : "エラーが発生しました");
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit} className="w-80 space-y-4 p-6">
<input name="email" type="email" placeholder="メール" aria-label="メール"
className="block w-full rounded border px-3 py-2 text-sm" />
<input name="password" type="password" placeholder="パスワード" aria-label="パスワード"
className="block w-full rounded border px-3 py-2 text-sm" />
<button type="submit" disabled={loading}
className="w-full rounded bg-blue-600 py-2 text-sm text-white disabled:opacity-50">
{loading ? "送信中..." : "ログイン"}
</button>
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
</form>
);
}
play 関数でインタラクションテストを書く
// stories/LoginForm.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { userEvent, within, expect, fn } from "@storybook/test";
import { LoginForm } from "@/components/LoginForm";
const meta: Meta<typeof LoginForm> = {
component: LoginForm,
title: "Components/LoginForm",
};
export default meta;
type Story = StoryObj<typeof LoginForm>;
// ハッピーパス: ログイン成功
export const Success: Story = {
args: {
onLogin: fn().mockResolvedValue(undefined),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
// フォームに入力
await userEvent.type(canvas.getByLabelText("メール"), "user@example.com");
await userEvent.type(canvas.getByLabelText("パスワード"), "password123");
// 送信ボタンをクリック
await userEvent.click(canvas.getByRole("button", { name: "ログイン" }));
// onLogin が正しい引数で呼ばれたかを検証
await expect(args.onLogin).toHaveBeenCalledWith("user@example.com", "password123");
},
};
// エラーパス: ログイン失敗
export const WithError: Story = {
args: {
onLogin: fn().mockRejectedValue(new Error("認証に失敗しました")),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText("メール"), "bad@example.com");
await userEvent.type(canvas.getByLabelText("パスワード"), "wrong");
await userEvent.click(canvas.getByRole("button", { name: "ログイン" }));
// エラーメッセージが表示されることを検証
const alert = await canvas.findByRole("alert");
await expect(alert).toHaveTextContent("認証に失敗しました");
},
};
// 送信中の状態
export const Loading: Story = {
args: {
// 解決しない Promise で送信中状態を維持
onLogin: fn(() => new Promise(() => {})),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText("メール"), "user@example.com");
await userEvent.type(canvas.getByLabelText("パスワード"), "password");
await userEvent.click(canvas.getByRole("button", { name: "ログイン" }));
// ボタンが disabled になることを検証
await expect(canvas.getByRole("button", { name: "送信中..." })).toBeDisabled();
},
};
.storybook/main.ts への addon 追加
// .storybook/main.ts
import type { StorybookConfig } from "@storybook/nextjs";
const config: StorybookConfig = {
addons: [
"@storybook/addon-essentials",
"@storybook/addon-interactions", // ← 追加
],
framework: "@storybook/nextjs",
};
export default config;
ポイント
play関数は{ canvasElement, args }を受け取る。within(canvasElement)でスコープを Story 内に限定し、getByRole/getByLabelTextでアクセシブルなクエリを使うfn()は Storybook 組み込みのモック関数。mockResolvedValue/mockRejectedValueでコールバックの成功・失敗を制御し、toHaveBeenCalledWithで呼び出しを検証できる- 非同期の状態変化(エラー表示など)は
canvas.findByRoleを使う。findBy*は要素が出現するまで自動で待機する @storybook/addon-interactionsを追加すると Storybook UI 上で play 関数の実行ステップを確認・再実行できるjest-component-testとの使い分け: jsdom 上でロジックを検証する場合は Jest。ブラウザ上で実際に描画した状態を視覚確認しながらインタラクションも検証したい場合は Storybook の play 関数