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

Storybook でコンポーネントの Story を書く

Storybook 8 の CSF3 形式で UI コンポーネントの Story を作成する例。args / argTypes / play 関数を使ったインタラクティブテストのパターンを示す。

nextjstestingui-componentstorybook

対応バージョン

nextjs 15react 19storybook 8

前提環境

Storybook の基本的なセットアップと React コンポーネントの書き方を理解していること

概要

Storybook 8 の CSF3(Component Story Format 3)形式で UI コンポーネントの Story を作成する。args / argTypes によるプロパティ制御と、play 関数を使ったインタラクティブテストのパターンを示す。

インストール

npx storybook@latest init
# Next.js プロジェクトでは @storybook/nextjs が自動選択される

実装

テスト対象コンポーネント

// components/Button.tsx
type Variant = "primary" | "secondary" | "danger";

type Props = {
  label: string;
  variant?: Variant;
  disabled?: boolean;
  onClick?: () => void;
};

const variantClasses: Record<Variant, string> = {
  primary: "bg-blue-600 text-white hover:bg-blue-700",
  secondary: "bg-gray-100 text-gray-800 hover:bg-gray-200",
  danger: "bg-red-600 text-white hover:bg-red-700",
};

export function Button({ label, variant = "primary", disabled, onClick }: Props) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className={`rounded px-4 py-2 text-sm font-medium transition-colors
        ${variantClasses[variant]}
        disabled:cursor-not-allowed disabled:opacity-40`}
    >
      {label}
    </button>
  );
}

Story ファイル

// components/Button.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { expect, fn, userEvent, within } from "@storybook/test";
import { Button } from "./Button";

const meta: Meta<typeof Button> = {
  title: "Components/Button",
  component: Button,
  // args に共通の props を定義
  args: {
    onClick: fn(), // spy 関数。呼び出し回数・引数を検証できる
  },
  // argTypes でコントロールパネルの UI をカスタマイズ
  argTypes: {
    variant: {
      control: "select",
      options: ["primary", "secondary", "danger"],
    },
  },
};

export default meta;
type Story = StoryObj<typeof Button>;

// 基本ストーリー
export const Primary: Story = {
  args: {
    label: "送信する",
    variant: "primary",
  },
};

export const Secondary: Story = {
  args: {
    label: "キャンセル",
    variant: "secondary",
  },
};

export const Danger: Story = {
  args: {
    label: "削除する",
    variant: "danger",
  },
};

export const Disabled: Story = {
  args: {
    label: "送信する",
    disabled: true,
  },
};

// play 関数でインタラクションをテスト
export const ClickInteraction: Story = {
  args: {
    label: "クリックしてね",
  },
  play: async ({ canvasElement, args }) => {
    const canvas = within(canvasElement);
    const button = canvas.getByRole("button", { name: "クリックしてね" });

    await userEvent.click(button);

    // onClick が1回呼ばれたことを検証
    expect(args.onClick).toHaveBeenCalledTimes(1);
  },
};

export const DisabledNoClick: Story = {
  args: {
    label: "無効ボタン",
    disabled: true,
  },
  play: async ({ canvasElement, args }) => {
    const canvas = within(canvasElement);
    const button = canvas.getByRole("button", { name: "無効ボタン" });

    await userEvent.click(button);

    // disabled なので onClick は呼ばれない
    expect(args.onClick).not.toHaveBeenCalled();
  },
};

ポイント

  • CSF3 形式では meta オブジェクトを default export し、各 Story を named export で定義する
  • args に共通 props を定義すると全 Story に継承される。Story レベルの args は上書きできる
  • fn() を使うと Storybook のアクションパネルに呼び出しログが表示され、play 関数内で expect(fn).toHaveBeenCalledTimes(1) と検証できる
  • play 関数は @storybook/testuserEvent でクリック・入力・フォーカスをシミュレートし、Story ページでインタラクティブに実行できる
  • @storybook/nextjs アダプターを使うと Next.js の Image / Link / useRouter が自動でモックされるため、追加設定なしで Next.js コンポーネントの Story が書ける

注意点

Storybook 8 では @storybook/nextjs アダプターを使うことで Next.js 固有の機能(Image / Link / Router)が自動でモックされる。

関連サンプル