レビュー待ち·難易度: 中級·更新: 2026-04-18

Storybook の play 関数でインタラクションテストを書く

@storybook/test の userEvent と expect を使い、play 関数でユーザー操作をシミュレートしてコンポーネントの動作を Story 内で自動検証する例。

nextjstestingstorybook

対応バージョン

nextjs 15react 19storybook 8

前提環境

Storybook の基本的なストーリー定義(storybook-component-story)を理解していること

概要

@storybook/testuserEventexpectplay 関数に記述することで、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 関数

注意点

storybook-component-story は args / controls を使った基本ストーリー定義。これは @storybook/test の play 関数でユーザー操作をシミュレートし Story 内でアサーションを実行するインタラクションテストパターン。

関連サンプル