概要
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/testのuserEventでクリック・入力・フォーカスをシミュレートし、Story ページでインタラクティブに実行できる@storybook/nextjsアダプターを使うと Next.js のImage/Link/useRouterが自動でモックされるため、追加設定なしで Next.js コンポーネントの Story が書ける