T-CREATOR

Storybook で学ぶコンポーネントテスト戦略

Storybook で学ぶコンポーネントテスト戦略

モダンなフロントエンド開発において、コンポーネントベースの設計が主流となった今日、開発者の皆さんは新たな挑戦に直面していませんか。美しい UI コンポーネントを作ることはできても、その品質を継続的に保証し、チーム全体で一貫性を保つことは決して簡単ではありません。

特に、React や Vue.js を使った大規模なアプリケーション開発では、数百個ものコンポーネントが相互に連携し合います。そんな複雑な環境で、どうすれば効率的かつ確実にテストできるのでしょうか。

そこで注目されているのが、Storybook を活用したコンポーネントテスト戦略です。単なるコンポーネントカタログとしてではなく、包括的なテスト環境として Storybook を活用することで、開発チーム全体の生産性と品質を大幅に向上させることができるのです。

背景

コンポーネント開発における品質保証の重要性

現代の Web アプリケーション開発では、再利用可能なコンポーネントが開発効率とメンテナンス性の向上において中核的な役割を担っています。しかし、コンポーネントの数が増加するにつれて、その品質を一貫して保証することは非常に困難な課題となってきました。

特に大規模なプロジェクトでは、以下のような品質保証の課題が顕在化しています。

課題分野具体的な問題影響度
視覚的品質デザインシステムからの逸脱
機能的品質Props 変更時の予期しない動作
アクセシビリティスクリーンリーダー対応不備
パフォーマンスレンダリング最適化の未実施

これらの課題を解決するには、従来の単体テストだけでは不十分です。コンポーネントの「見た目」と「振る舞い」の両方を包括的にテストする新しいアプローチが求められているのです。

従来のテスト手法の限界

多くの開発チームが採用してきた従来のテスト手法には、以下のような制約があります。

単体テストの限界 従来の Jest や Mocha を使った単体テストは、コンポーネントのロジック部分には有効ですが、視覚的な要素や実際のユーザー体験をテストすることができません。

javascript// 従来の単体テスト例
test('Button should handle click events', () => {
  const handleClick = jest.fn();
  render(<Button onClick={handleClick}>Click me</Button>);

  fireEvent.click(screen.getByRole('button'));
  expect(handleClick).toHaveBeenCalled();
});

このテストは機能的には正しく動作しますが、ボタンの見た目、色、サイズ、アニメーションなどの視覚的要素については何も保証していません。

E2E テストの課題 Selenium や Cypress を使った E2E テストは包括的ですが、実行時間が長く、環境依存の問題も多く発生します。また、コンポーネント単位でのテストには向いていません。

Storybook がテスト戦略に与える影響

Storybook の登場により、コンポーネントテストの概念は根本的に変化しました。単なる開発ツールから、包括的なテスト環境へと進化したのです。

以下の図は、Storybook を中心としたテスト戦略の全体像を示しています。

mermaidflowchart TD
    Dev[開発者] -->|コンポーネント作成| SB[Storybook]
    SB -->|Story作成| Stories[Stories]
    Stories -->|自動実行| VT[Visual Testing]
    Stories -->|自動実行| IT[Interaction Testing]
    Stories -->|自動実行| AT[Accessibility Testing]

    VT -->|差分検知| CR[Chromatic]
    IT -->|ユーザー操作| Play[Play Functions]
    AT -->|a11y監査| A11y[Accessibility Addon]

    CR -->|結果| Report[テストレポート]
    Play -->|結果| Report
    A11y -->|結果| Report

    Report -->|フィードバック| Dev

    subgraph 自動化環境
        CI[CI/CD Pipeline]
        CI -->|自動実行| VT
        CI -->|自動実行| IT
        CI -->|自動実行| AT
    end

この統合されたテスト環境により、開発者は一つのツールセットで多層的なテスト戦略を実現できるようになりました。

課題

コンポーネントの視覚的な確認の困難さ

コンポーネント開発における最も大きな課題の一つは、視覚的な品質を継続的に保証することです。従来の開発プロセスでは、以下のような問題が頻繁に発生していました。

デザイン仕様との乖離 デザイナーが作成したモックアップと実装されたコンポーネントの間に微妙な差異が生じることがあります。これらの差異は、個別のレビューでは見過ごされがちですが、アプリケーション全体では大きな不統一を生み出してしまいます。

typescript// 問題のあるコンポーネント例
interface ButtonProps {
  variant: 'primary' | 'secondary';
  size: 'small' | 'medium' | 'large';
}

const Button: React.FC<ButtonProps> = ({
  variant,
  size,
}) => {
  // 各 variant × size の組み合わせでの見た目を
  // 手動で確認するのは非効率的
  return (
    <button className={`btn btn-${variant} btn-${size}`}>
      ボタン
    </button>
  );
};

ブラウザ間・デバイス間の差異 CSS の実装差異により、Chrome では正常に表示されるコンポーネントが、Safari や Firefox では崩れて見えることがあります。また、モバイルデバイスでのタッチ操作やレスポンシブデザインの確認も、手動では限界があります。

状態管理とテストの複雑性

モダンなコンポーネントは、多くの場合複雑な状態を持ちます。この状態管理とテストの組み合わせが、開発者にとって大きな負担となっています。

動的な状態変化の再現困難性 ユーザーの操作によって状態が変化するコンポーネントの場合、すべての状態パターンを手動でテストすることは現実的ではありません。

以下の図は、典型的なフォームコンポーネントの状態遷移を示しています。

mermaidstateDiagram-v2
    [*] --> Initial: コンポーネント初期化
    Initial --> Typing: ユーザー入力開始
    Typing --> Validating: バリデーション実行
    Validating --> Valid: 入力値が正常
    Validating --> Invalid: 入力値にエラー
    Valid --> Submitting: 送信ボタンクリック
    Invalid --> Typing: ユーザーが入力修正
    Submitting --> Success: API呼び出し成功
    Submitting --> Error: API呼び出し失敗
    Success --> [*]: 処理完了
    Error --> Typing: エラー後の再入力

これらの状態をすべて手動でテストするには、膨大な時間とコストが必要になります。

外部依存関係のモック化 API コールやローカルストレージへのアクセスなど、外部依存関係を持つコンポーネントのテストでは、適切なモック化が重要です。しかし、現実的なテストデータの準備や、エラーケースの再現は容易ではありません。

チーム間での品質基準の統一

大規模な開発チームでは、メンバー間で品質基準や実装スタイルに差が生じることが避けられません。この問題は、以下のような形で現れます。

コードレビューの属人化 経験豊富なシニア開発者がすべてのコンポーネントをレビューすることは現実的ではありません。結果として、レビューの質にばらつきが生じ、品質の不統一が発生します。

デザインシステムの形骸化 詳細なデザインシステムが存在しても、実装時にそれらのガイドラインが守られているかを継続的に確認する仕組みがなければ、次第に形骸化してしまいます。

チーム課題発生頻度対処の難易度影響範囲
デザイン仕様の解釈違いUI 全体
アクセシビリティ実装の差異UX 全体
パフォーマンス最適化の未実施動作速度
エラーハンドリングの不統一開発効率

これらの課題を解決するには、自動化された品質チェックの仕組みと、チーム全体で共有できる明確な基準が必要です。

解決策

Storybook を活用したコンポーネントテスト戦略

これらの課題を解決するため、Storybook を中心とした包括的なテスト戦略を構築していきましょう。この戦略は、従来の単体テストと E2E テストの間にあるギャップを埋め、コンポーネント単位での効率的かつ確実なテストを実現します。

Storybook テスト戦略の基本概念

Storybook を活用したテスト戦略では、以下の 3 つの柱を中心に据えます。

  1. Visual Regression Testing: コンポーネントの視覚的な変化を自動検知
  2. Interaction Testing: ユーザーの操作をシミュレートした動作テスト
  3. Accessibility Testing: アクセシビリティ基準への準拠確認
typescript// Storybook Story の基本構造
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';

const meta: Meta<typeof Button> = {
  title: 'Components/Button',
  component: Button,
  parameters: {
    // Visual Testing の設定
    chromatic: { delay: 300 },
    // Accessibility Testing の設定
    a11y: {
      config: {
        rules: [{ id: 'color-contrast', enabled: true }],
      },
    },
  },
};

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

以下の図は、Storybook を中心としたテスト戦略の実行フローを示しています。

mermaidflowchart LR
    subgraph 開発フェーズ
        Code[コード作成]
        Story[Story作成]
        Code --> Story
    end

    subgraph Storybook環境
        SB[Storybook起動]
        Stories[Stories表示]
        Story --> SB
        SB --> Stories
    end

    subgraph 自動テスト実行
        VT[Visual Test]
        IT[Interaction Test]
        AT[Accessibility Test]
        Stories --> VT
        Stories --> IT
        Stories --> AT
    end

    subgraph 結果統合
        Report[統合レポート]
        VT --> Report
        IT --> Report
        AT --> Report
    end

    Report -->|フィードバック| Code

段階的な導入アプローチ

既存のプロジェクトにこの戦略を導入する際は、以下の段階に分けて実施することをお勧めします。

フェーズ期間目安主な作業内容成果物
1. 基盤構築1-2 週間Storybook 環境構築、基本設定動作する Storybook
2. Story 作成2-3 週間既存コンポーネントの Story 化主要コンポーネントの Story
3. テスト自動化2-3 週間CI/CD 統合、テスト設定自動テスト環境
4. 運用最適化継続的ルール策定、チーム教育運用ガイドライン

Visual Testing と Unit Testing の融合

従来のテスト手法では、ロジックのテスト(Unit Testing)と見た目のテスト(Visual Testing)は完全に分離されていました。しかし、Storybook を活用することで、これらを統合した効率的なテストが実現できます。

統合テストの利点

typescript// 統合されたテストの例
import { expect } from '@storybook/jest';
import {
  within,
  userEvent,
} from '@storybook/testing-library';

export const InteractiveButton: Story = {
  args: {
    variant: 'primary',
    children: 'クリックして確認',
  },
  // Visual Testing: スナップショット比較が自動実行
  // Unit Testing: 以下のplay関数で動作確認
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    const button = canvas.getByRole('button');

    // 初期状態の確認(視覚的・機能的両方)
    await expect(button).toBeInTheDocument();
    await expect(button).toHaveClass('btn-primary');

    // インタラクションの実行と検証
    await userEvent.click(button);

    // クリック後の状態確認(アニメーション含む)
    await expect(button).toHaveClass('btn-clicked');
  },
};

テストカバレッジの向上

この統合アプローチにより、以下のようなテストカバレッジの向上が期待できます。

  • 機能テスト: コンポーネントの基本動作確認
  • 視覚テスト: デザイン仕様との整合性確認
  • インタラクションテスト: ユーザー操作のシミュレート
  • レスポンシブテスト: 各画面サイズでの表示確認
  • アクセシビリティテスト: WCAG 基準への準拠確認

CI/CD パイプラインとの統合

Storybook を活用したテスト戦略の真価は、CI/CD パイプラインとの統合によって発揮されます。これにより、コードの変更があるたびに自動的に包括的なテストが実行され、品質の継続的な保証が実現できます。

GitHub Actions での統合例

yaml# .github/workflows/storybook-tests.yml
name: Storybook Tests

on:
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'yarn'

      - name: Install dependencies
        run: yarn install --frozen-lockfile

      - name: Build Storybook
        run: yarn build-storybook

      - name: Run Visual Tests
        run: yarn chromatic --project-token=${{ secrets.CHROMATIC_PROJECT_TOKEN }}

      - name: Run Interaction Tests
        run: yarn test-storybook

      - name: Run Accessibility Tests
        run: yarn test-storybook --config-dir .storybook --test-runner-config test-runner-jest.config.js

統合による効果

統合領域自動化される内容検知できる問題
Visual Testingスクリーンショット比較デザイン崩れ、色の変更
Interaction Testingユーザー操作シミュレート機能不具合、UX 問題
Accessibility Testinga11y 監査アクセシビリティ違反
Performance Testingレンダリング時間測定パフォーマンス劣化

継続的インテグレーションの流れ

mermaidsequenceDiagram
    participant Dev as 開発者
    participant GitHub as GitHub
    participant CI as CI/CD
    participant Chromatic as Chromatic
    participant Report as レポート

    Dev->>GitHub: Pull Request作成
    GitHub->>CI: webhook発火
    CI->>CI: Storybook Build

    par Visual Testing
        CI->>Chromatic: スクリーンショット送信
        Chromatic->>Chromatic: 差分比較実行
        Chromatic->>Report: 結果記録
    and Interaction Testing
        CI->>CI: Play Functions実行
        CI->>Report: 結果記録
    and Accessibility Testing
        CI->>CI: a11y監査実行
        CI->>Report: 結果記録
    end

    Report->>GitHub: テスト結果通知
    GitHub->>Dev: PR画面に結果表示

この自動化により、開発者は手動でのテスト作業から解放され、より創造的な開発作業に集中できるようになります。また、品質の客観的な基準が確立されることで、チーム全体での品質向上も実現できるのです。

具体例

基本的なコンポーネントテスト

実際の開発現場で最も頻繁に使用される基本的なコンポーネントを例に、Storybook を活用したテスト戦略を詳しく見ていきましょう。

Button コンポーネントのストーリー作成

Button コンポーネントは、どんなアプリケーションにも存在する基本的な UI 要素です。しかし、その単純さゆえに見落とされがちなテストケースが数多く存在します。

まず、基本的な Button コンポーネントの実装を確認しましょう。

typescript// components/Button/Button.tsx
import React from 'react';
import {
  cva,
  type VariantProps,
} from 'class-variance-authority';
import { cn } from '@/lib/utils';

const buttonVariants = cva(
  'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors',
  {
    variants: {
      variant: {
        primary: 'bg-blue-600 text-white hover:bg-blue-700',
        secondary:
          'bg-gray-200 text-gray-900 hover:bg-gray-300',
        destructive:
          'bg-red-600 text-white hover:bg-red-700',
      },
      size: {
        small: 'h-8 px-3 py-1',
        medium: 'h-10 px-4 py-2',
        large: 'h-12 px-6 py-3',
      },
    },
    defaultVariants: {
      variant: 'primary',
      size: 'medium',
    },
  }
);

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  children: React.ReactNode;
  loading?: boolean;
}

export const Button = React.forwardRef<
  HTMLButtonElement,
  ButtonProps
>(
  (
    {
      className,
      variant,
      size,
      loading,
      children,
      ...props
    },
    ref
  ) => {
    return (
      <button
        className={cn(
          buttonVariants({ variant, size, className })
        )}
        ref={ref}
        disabled={loading || props.disabled}
        {...props}
      >
        {loading && (
          <span className='mr-2'>読み込み中...</span>
        )}
        {children}
      </button>
    );
  }
);

次に、このコンポーネントの包括的な Story を作成します。

typescript// components/Button/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import { Button } from './Button';

const meta: Meta<typeof Button> = {
  title: 'Components/Button',
  component: Button,
  parameters: {
    layout: 'centered',
    // Visual regression testingの設定
    chromatic: {
      delay: 100,
      // 異なるビューポートでのテスト
      viewports: [320, 768, 1024],
    },
  },
  tags: ['autodocs'],
  argTypes: {
    variant: {
      control: { type: 'select' },
      options: ['primary', 'secondary', 'destructive'],
    },
    size: {
      control: { type: 'select' },
      options: ['small', 'medium', 'large'],
    },
    loading: {
      control: { type: 'boolean' },
    },
    disabled: {
      control: { type: 'boolean' },
    },
  },
  args: {
    onClick: fn(),
  },
};

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

Props のバリエーションテスト

Button コンポーネントの全てのプロパティの組み合わせを網羅的にテストするため、以下のような Story を作成します。

typescript// 基本的なバリエーション
export const Primary: Story = {
  args: {
    variant: 'primary',
    children: 'プライマリボタン',
  },
};

export const Secondary: Story = {
  args: {
    variant: 'secondary',
    children: 'セカンダリボタン',
  },
};

export const Destructive: Story = {
  args: {
    variant: 'destructive',
    children: '削除ボタン',
  },
};

// サイズバリエーション
export const Small: Story = {
  args: {
    size: 'small',
    children: '小さなボタン',
  },
};

export const Large: Story = {
  args: {
    size: 'large',
    children: '大きなボタン',
  },
};

// 状態バリエーション
export const Loading: Story = {
  args: {
    loading: true,
    children: '処理実行',
  },
};

export const Disabled: Story = {
  args: {
    disabled: true,
    children: '無効なボタン',
  },
};

全組み合わせを表示するマトリックス Story

全てのバリエーションを一画面で確認できるマトリックス表示も作成しましょう。

typescript// components/Button/Button.stories.tsx(続き)
export const AllVariants: Story = {
  render: () => (
    <div className='space-y-4'>
      <div className='space-y-2'>
        <h3 className='text-lg font-semibold'>
          サイズバリエーション
        </h3>
        <div className='flex items-center gap-4'>
          <Button size='small'>Small</Button>
          <Button size='medium'>Medium</Button>
          <Button size='large'>Large</Button>
        </div>
      </div>

      <div className='space-y-2'>
        <h3 className='text-lg font-semibold'>
          カラーバリエーション
        </h3>
        <div className='flex items-center gap-4'>
          <Button variant='primary'>Primary</Button>
          <Button variant='secondary'>Secondary</Button>
          <Button variant='destructive'>Destructive</Button>
        </div>
      </div>

      <div className='space-y-2'>
        <h3 className='text-lg font-semibold'>
          状態バリエーション
        </h3>
        <div className='flex items-center gap-4'>
          <Button>通常</Button>
          <Button loading>読み込み中</Button>
          <Button disabled>無効</Button>
        </div>
      </div>
    </div>
  ),
};

インタラクションテストの実装

Storybook の Play Functions を使用して、ユーザーの操作をシミュレートしたテストを実装します。

typescript// components/Button/Button.stories.tsx(続き)
import { expect } from '@storybook/jest';
import {
  within,
  userEvent,
} from '@storybook/testing-library';

export const InteractionTest: Story = {
  args: {
    variant: 'primary',
    children: 'クリックテスト',
  },
  play: async ({ canvasElement, args }) => {
    const canvas = within(canvasElement);
    const button = canvas.getByRole('button');

    // 初期状態の確認
    await expect(button).toBeInTheDocument();
    await expect(button).toHaveTextContent(
      'クリックテスト'
    );
    await expect(button).not.toBeDisabled();

    // ホバー状態の確認
    await userEvent.hover(button);
    await expect(button).toHaveClass('hover:bg-blue-700');

    // クリック操作の実行
    await userEvent.click(button);

    // onClick ハンドラーが呼ばれたことを確認
    await expect(args.onClick).toHaveBeenCalled();
    await expect(args.onClick).toHaveBeenCalledTimes(1);
  },
};

// キーボード操作のテスト
export const KeyboardInteraction: Story = {
  args: {
    variant: 'primary',
    children: 'キーボードテスト',
  },
  play: async ({ canvasElement, args }) => {
    const canvas = within(canvasElement);
    const button = canvas.getByRole('button');

    // フォーカス確認
    button.focus();
    await expect(button).toHaveFocus();

    // Enterキーでの実行
    await userEvent.keyboard('{Enter}');
    await expect(args.onClick).toHaveBeenCalled();

    // Spaceキーでの実行
    await userEvent.keyboard('{Space}');
    await expect(args.onClick).toHaveBeenCalledTimes(2);
  },
};

アクセシビリティテストの統合

typescript// .storybook/test-runner.js
const { injectAxe, checkA11y } = require('axe-playwright');

module.exports = {
  async preRender(page) {
    await injectAxe(page);
  },
  async postRender(page) {
    await checkA11y(page, '#root', {
      detailedReport: true,
      detailedReportOptions: {
        html: true,
      },
    });
  },
};

複雑なコンポーネントテスト

基本的なコンポーネントのテスト戦略を理解したところで、より複雑なコンポーネントのテストに進みましょう。ここでは、実際のアプリケーションでよく使用される高度な機能を持つコンポーネントを例に説明します。

Form コンポーネントの状態管理テスト

フォームコンポーネントは、ユーザー入力、バリデーション、エラーハンドリングなど、多くの状態を管理する複雑なコンポーネントです。

typescript// components/ContactForm/ContactForm.tsx
import React, { useState } from 'react';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

const contactSchema = z.object({
  name: z
    .string()
    .min(1, '名前は必須です')
    .min(2, '名前は2文字以上で入力してください'),
  email: z
    .string()
    .min(1, 'メールアドレスは必須です')
    .email('正しいメールアドレスを入力してください'),
  message: z
    .string()
    .min(1, 'メッセージは必須です')
    .min(10, 'メッセージは10文字以上で入力してください'),
});

type ContactFormData = z.infer<typeof contactSchema>;

interface ContactFormProps {
  onSubmit: (data: ContactFormData) => Promise<void>;
  initialValues?: Partial<ContactFormData>;
}

export const ContactForm: React.FC<ContactFormProps> = ({
  onSubmit,
  initialValues,
}) => {
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [submitError, setSubmitError] = useState<
    string | null
  >(null);
  const [submitSuccess, setSubmitSuccess] = useState(false);

  const {
    register,
    handleSubmit,
    formState: { errors, isValid },
    reset,
  } = useForm<ContactFormData>({
    resolver: zodResolver(contactSchema),
    defaultValues: initialValues,
    mode: 'onChange',
  });

  const handleFormSubmit = async (
    data: ContactFormData
  ) => {
    setIsSubmitting(true);
    setSubmitError(null);
    setSubmitSuccess(false);

    try {
      await onSubmit(data);
      setSubmitSuccess(true);
      reset();
    } catch (error) {
      setSubmitError(
        error instanceof Error
          ? error.message
          : '送信エラーが発生しました'
      );
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form
      onSubmit={handleSubmit(handleFormSubmit)}
      className='space-y-4'
    >
      {submitSuccess && (
        <div className='p-4 bg-green-50 border border-green-200 rounded-md'>
          <p className='text-green-800'>
            お問い合わせを送信しました
          </p>
        </div>
      )}

      {submitError && (
        <div className='p-4 bg-red-50 border border-red-200 rounded-md'>
          <p className='text-red-800'>{submitError}</p>
        </div>
      )}

      <div>
        <label
          htmlFor='name'
          className='block text-sm font-medium text-gray-700'
        >
          お名前 *
        </label>
        <input
          {...register('name')}
          type='text'
          id='name'
          className={`mt-1 block w-full rounded-md border ${
            errors.name
              ? 'border-red-300'
              : 'border-gray-300'
          } px-3 py-2`}
          aria-invalid={errors.name ? 'true' : 'false'}
        />
        {errors.name && (
          <p
            className='mt-1 text-sm text-red-600'
            role='alert'
          >
            {errors.name.message}
          </p>
        )}
      </div>

      <div>
        <label
          htmlFor='email'
          className='block text-sm font-medium text-gray-700'
        >
          メールアドレス *
        </label>
        <input
          {...register('email')}
          type='email'
          id='email'
          className={`mt-1 block w-full rounded-md border ${
            errors.email
              ? 'border-red-300'
              : 'border-gray-300'
          } px-3 py-2`}
          aria-invalid={errors.email ? 'true' : 'false'}
        />
        {errors.email && (
          <p
            className='mt-1 text-sm text-red-600'
            role='alert'
          >
            {errors.email.message}
          </p>
        )}
      </div>

      <div>
        <label
          htmlFor='message'
          className='block text-sm font-medium text-gray-700'
        >
          メッセージ *
        </label>
        <textarea
          {...register('message')}
          id='message'
          rows={4}
          className={`mt-1 block w-full rounded-md border ${
            errors.message
              ? 'border-red-300'
              : 'border-gray-300'
          } px-3 py-2`}
          aria-invalid={errors.message ? 'true' : 'false'}
        />
        {errors.message && (
          <p
            className='mt-1 text-sm text-red-600'
            role='alert'
          >
            {errors.message.message}
          </p>
        )}
      </div>

      <button
        type='submit'
        disabled={isSubmitting || !isValid}
        className='w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed'
      >
        {isSubmitting ? '送信中...' : '送信する'}
      </button>
    </form>
  );
};

Form コンポーネントの包括的な Story

typescript// components/ContactForm/ContactForm.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import { expect } from '@storybook/jest';
import {
  within,
  userEvent,
  waitFor,
} from '@storybook/testing-library';
import { ContactForm } from './ContactForm';

const meta: Meta<typeof ContactForm> = {
  title: 'Components/ContactForm',
  component: ContactForm,
  parameters: {
    layout: 'centered',
  },
  decorators: [
    (Story) => (
      <div className='max-w-md mx-auto p-6'>
        <Story />
      </div>
    ),
  ],
};

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

// 基本的なフォーム
export const Default: Story = {
  args: {
    onSubmit: fn().mockResolvedValue(undefined),
  },
};

// 初期値あり
export const WithInitialValues: Story = {
  args: {
    onSubmit: fn().mockResolvedValue(undefined),
    initialValues: {
      name: '田中太郎',
      email: 'tanaka@example.com',
      message: 'お問い合わせ内容のサンプルテキストです。',
    },
  },
};

フォーム状態の包括的テスト

typescript// バリデーションエラーのテスト
export const ValidationErrors: Story = {
  args: {
    onSubmit: fn().mockResolvedValue(undefined),
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    // 空の状態で送信ボタンをクリック(ボタンは無効化されているはず)
    const submitButton = canvas.getByRole('button', {
      name: /送信する/,
    });
    await expect(submitButton).toBeDisabled();

    // 無効な値を入力してバリデーションエラーを確認
    const nameInput = canvas.getByLabelText('お名前 *');
    const emailInput =
      canvas.getByLabelText('メールアドレス *');
    const messageInput =
      canvas.getByLabelText('メッセージ *');

    // 短すぎる名前を入力
    await userEvent.type(nameInput, 'a');
    await waitFor(() => {
      expect(
        canvas.getByText(
          '名前は2文字以上で入力してください'
        )
      ).toBeInTheDocument();
    });

    // 無効なメールアドレスを入力
    await userEvent.type(emailInput, 'invalid-email');
    await waitFor(() => {
      expect(
        canvas.getByText(
          '正しいメールアドレスを入力してください'
        )
      ).toBeInTheDocument();
    });

    // 短すぎるメッセージを入力
    await userEvent.type(messageInput, '短い');
    await waitFor(() => {
      expect(
        canvas.getByText(
          'メッセージは10文字以上で入力してください'
        )
      ).toBeInTheDocument();
    });
  },
};

// 成功時の動作テスト
export const SuccessfulSubmission: Story = {
  args: {
    onSubmit: fn().mockImplementation(
      () =>
        new Promise((resolve) => setTimeout(resolve, 1000))
    ),
  },
  play: async ({ canvasElement, args }) => {
    const canvas = within(canvasElement);

    // 有効な値を入力
    await userEvent.type(
      canvas.getByLabelText('お名前 *'),
      '田中太郎'
    );
    await userEvent.type(
      canvas.getByLabelText('メールアドレス *'),
      'tanaka@example.com'
    );
    await userEvent.type(
      canvas.getByLabelText('メッセージ *'),
      'これは有効なメッセージです。10文字以上含まれています。'
    );

    // 送信ボタンが有効化されることを確認
    const submitButton = canvas.getByRole('button', {
      name: /送信する/,
    });
    await waitFor(() => {
      expect(submitButton).not.toBeDisabled();
    });

    // フォーム送信
    await userEvent.click(submitButton);

    // 送信中の状態確認
    await expect(
      canvas.getByRole('button', { name: /送信中.../ })
    ).toBeInTheDocument();
    await expect(
      canvas.getByRole('button', { name: /送信中.../ })
    ).toBeDisabled();

    // 送信完了の確認
    await waitFor(
      () => {
        expect(
          canvas.getByText('お問い合わせを送信しました')
        ).toBeInTheDocument();
      },
      { timeout: 2000 }
    );

    // onSubmitが呼ばれたことを確認
    await expect(args.onSubmit).toHaveBeenCalledWith({
      name: '田中太郎',
      email: 'tanaka@example.com',
      message:
        'これは有効なメッセージです。10文字以上含まれています。',
    });
  },
};

// エラー時の動作テスト
export const SubmissionError: Story = {
  args: {
    onSubmit: fn().mockRejectedValue(
      new Error('サーバーエラーが発生しました')
    ),
  },
  play: async ({ canvasElement, args }) => {
    const canvas = within(canvasElement);

    // 有効な値を入力
    await userEvent.type(
      canvas.getByLabelText('お名前 *'),
      '田中太郎'
    );
    await userEvent.type(
      canvas.getByLabelText('メールアドレス *'),
      'tanaka@example.com'
    );
    await userEvent.type(
      canvas.getByLabelText('メッセージ *'),
      'これは有効なメッセージです。'
    );

    // フォーム送信
    const submitButton = canvas.getByRole('button', {
      name: /送信する/,
    });
    await userEvent.click(submitButton);

    // エラーメッセージの表示確認
    await waitFor(() => {
      expect(
        canvas.getByText('サーバーエラーが発生しました')
      ).toBeInTheDocument();
    });

    // フォームが再度操作可能になることを確認
    await expect(submitButton).not.toBeDisabled();
  },
};

モーダルコンポーネントのライフサイクルテスト

モーダルコンポーネントは、開閉状態、フォーカス管理、Escape キーでの閉じる動作など、複雑なライフサイクルを持っています。

typescript// components/Modal/Modal.tsx
import React, { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
  size?: 'small' | 'medium' | 'large';
}

export const Modal: React.FC<ModalProps> = ({
  isOpen,
  onClose,
  title,
  children,
  size = 'medium',
}) => {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (isOpen) {
      // 現在のフォーカス要素を記憶
      previousFocusRef.current =
        document.activeElement as HTMLElement;

      // モーダル内にフォーカス
      setTimeout(() => {
        const focusableElement =
          modalRef.current?.querySelector(
            'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
          ) as HTMLElement;
        focusableElement?.focus();
      }, 100);

      // Escキーでの閉じる処理
      const handleEscape = (e: KeyboardEvent) => {
        if (e.key === 'Escape') {
          onClose();
        }
      };

      document.addEventListener('keydown', handleEscape);
      document.body.style.overflow = 'hidden';

      return () => {
        document.removeEventListener(
          'keydown',
          handleEscape
        );
        document.body.style.overflow = 'unset';

        // フォーカスを元の要素に戻す
        if (previousFocusRef.current) {
          previousFocusRef.current.focus();
        }
      };
    }
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  const sizeClasses = {
    small: 'max-w-sm',
    medium: 'max-w-md',
    large: 'max-w-2xl',
  };

  return createPortal(
    <div className='fixed inset-0 z-50 flex items-center justify-center'>
      {/* オーバーレイ */}
      <div
        className='fixed inset-0 bg-black bg-opacity-50'
        onClick={onClose}
        aria-hidden='true'
      />

      {/* モーダルコンテンツ */}
      <div
        ref={modalRef}
        className={`relative bg-white rounded-lg shadow-xl ${sizeClasses[size]} w-full mx-4 max-h-[90vh] overflow-y-auto`}
        role='dialog'
        aria-modal='true'
        aria-labelledby='modal-title'
      >
        {/* ヘッダー */}
        <div className='flex items-center justify-between p-6 border-b border-gray-200'>
          <h2
            id='modal-title'
            className='text-xl font-semibold text-gray-900'
          >
            {title}
          </h2>
          <button
            type='button'
            onClick={onClose}
            className='text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500'
            aria-label='モーダルを閉じる'
          >
            <svg
              className='w-6 h-6'
              fill='none'
              viewBox='0 0 24 24'
              stroke='currentColor'
            >
              <path
                strokeLinecap='round'
                strokeLinejoin='round'
                strokeWidth={2}
                d='M6 18L18 6M6 6l12 12'
              />
            </svg>
          </button>
        </div>

        {/* コンテンツ */}
        <div className='p-6'>{children}</div>
      </div>
    </div>,
    document.body
  );
};

モーダルコンポーネントの Story

typescript// components/Modal/Modal.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import { expect } from '@storybook/jest';
import {
  within,
  userEvent,
} from '@storybook/testing-library';
import { Modal } from './Modal';
import { useState } from 'react';

const meta: Meta<typeof Modal> = {
  title: 'Components/Modal',
  component: Modal,
  parameters: {
    layout: 'fullscreen',
  },
};

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

// インタラクティブなモーダル
const InteractiveModal = () => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <button
        onClick={() => setIsOpen(true)}
        className='px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700'
      >
        モーダルを開く
      </button>

      <Modal
        isOpen={isOpen}
        onClose={() => setIsOpen(false)}
        title='サンプルモーダル'
      >
        <p>これはモーダルのコンテンツです。</p>
        <div className='mt-4 space-x-2'>
          <button
            onClick={() => setIsOpen(false)}
            className='px-4 py-2 bg-gray-200 text-gray-900 rounded-md hover:bg-gray-300'
          >
            キャンセル
          </button>
          <button
            onClick={() => setIsOpen(false)}
            className='px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700'
          >
            確認
          </button>
        </div>
      </Modal>
    </>
  );
};

export const Interactive: Story = {
  render: () => <InteractiveModal />,
};

// モーダルのライフサイクルテスト
export const LifecycleTest: Story = {
  render: () => <InteractiveModal />,
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    // 初期状態:モーダルは閉じている
    const openButton = canvas.getByRole('button', {
      name: /モーダルを開く/,
    });
    await expect(openButton).toBeInTheDocument();

    // モーダルは存在しない
    expect(
      canvas.queryByRole('dialog')
    ).not.toBeInTheDocument();

    // モーダルを開く
    await userEvent.click(openButton);

    // モーダルが表示されることを確認
    const modal = canvas.getByRole('dialog');
    await expect(modal).toBeInTheDocument();
    await expect(
      canvas.getByText('サンプルモーダル')
    ).toBeInTheDocument();

    // フォーカスがモーダル内のボタンに移ることを確認
    const cancelButton = canvas.getByRole('button', {
      name: /キャンセル/,
    });
    await expect(cancelButton).toHaveFocus();

    // Escキーでモーダルを閉じる
    await userEvent.keyboard('{Escape}');

    // モーダルが閉じることを確認
    expect(
      canvas.queryByRole('dialog')
    ).not.toBeInTheDocument();

    // フォーカスが元のボタンに戻ることを確認
    await expect(openButton).toHaveFocus();
  },
};

// オーバーレイクリックのテスト
export const OverlayClickTest: Story = {
  render: () => <InteractiveModal />,
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    const openButton = canvas.getByRole('button', {
      name: /モーダルを開く/,
    });

    // モーダルを開く
    await userEvent.click(openButton);

    // オーバーレイをクリック(モーダルコンテンツ以外の場所)
    const overlay = document.querySelector(
      '.bg-black.bg-opacity-50'
    );
    if (overlay) {
      await userEvent.click(overlay as Element);
    }

    // モーダルが閉じることを確認
    expect(
      canvas.queryByRole('dialog')
    ).not.toBeInTheDocument();
  },
};

データ連携コンポーネントのモックテスト

実際のアプリケーションでは、API からデータを取得して表示するコンポーネントが多く存在します。これらのコンポーネントのテストでは、外部依存関係を適切にモック化する必要があります。

typescript// components/UserList/UserList.tsx
import React, { useEffect, useState } from 'react';

interface User {
  id: number;
  name: string;
  email: string;
  avatar: string;
  status: 'active' | 'inactive';
}

interface UserListProps {
  apiClient: {
    fetchUsers: () => Promise<User[]>;
    updateUserStatus: (
      id: number,
      status: User['status']
    ) => Promise<void>;
  };
  onUserClick?: (user: User) => void;
}

export const UserList: React.FC<UserListProps> = ({
  apiClient,
  onUserClick,
}) => {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [updatingUsers, setUpdatingUsers] = useState<
    Set<number>
  >(new Set());

  useEffect(() => {
    const loadUsers = async () => {
      try {
        setLoading(true);
        setError(null);
        const fetchedUsers = await apiClient.fetchUsers();
        setUsers(fetchedUsers);
      } catch (err) {
        setError('ユーザー一覧の取得に失敗しました');
      } finally {
        setLoading(false);
      }
    };

    loadUsers();
  }, [apiClient]);

  const handleStatusToggle = async (user: User) => {
    const newStatus =
      user.status === 'active' ? 'inactive' : 'active';

    setUpdatingUsers((prev) => new Set(prev).add(user.id));

    try {
      await apiClient.updateUserStatus(user.id, newStatus);
      setUsers((prevUsers) =>
        prevUsers.map((u) =>
          u.id === user.id ? { ...u, status: newStatus } : u
        )
      );
    } catch (err) {
      setError('ステータスの更新に失敗しました');
    } finally {
      setUpdatingUsers((prev) => {
        const next = new Set(prev);
        next.delete(user.id);
        return next;
      });
    }
  };

  if (loading) {
    return (
      <div className='flex justify-center items-center p-8'>
        <div className='text-gray-600'>読み込み中...</div>
      </div>
    );
  }

  if (error) {
    return (
      <div className='p-4 bg-red-50 border border-red-200 rounded-md'>
        <p className='text-red-800'>{error}</p>
      </div>
    );
  }

  return (
    <div className='space-y-2'>
      {users.map((user) => (
        <div
          key={user.id}
          className='flex items-center justify-between p-4 bg-white border border-gray-200 rounded-md hover:bg-gray-50 cursor-pointer'
          onClick={() => onUserClick?.(user)}
        >
          <div className='flex items-center space-x-3'>
            <img
              src={user.avatar}
              alt={`${user.name}のアバター`}
              className='w-10 h-10 rounded-full'
            />
            <div>
              <div className='font-medium text-gray-900'>
                {user.name}
              </div>
              <div className='text-sm text-gray-500'>
                {user.email}
              </div>
            </div>
          </div>

          <div className='flex items-center space-x-2'>
            <span
              className={`px-2 py-1 text-xs rounded-full ${
                user.status === 'active'
                  ? 'bg-green-100 text-green-800'
                  : 'bg-gray-100 text-gray-800'
              }`}
            >
              {user.status === 'active'
                ? 'アクティブ'
                : '非アクティブ'}
            </span>

            <button
              onClick={(e) => {
                e.stopPropagation();
                handleStatusToggle(user);
              }}
              disabled={updatingUsers.has(user.id)}
              className='px-3 py-1 text-xs bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50'
            >
              {updatingUsers.has(user.id)
                ? '更新中...'
                : 'ステータス変更'}
            </button>
          </div>
        </div>
      ))}
    </div>
  );
};

データ連携コンポーネントの Story

typescript// components/UserList/UserList.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import { expect } from '@storybook/jest';
import {
  within,
  userEvent,
  waitFor,
} from '@storybook/testing-library';
import { UserList } from './UserList';

// モックデータ
const mockUsers = [
  {
    id: 1,
    name: '田中太郎',
    email: 'tanaka@example.com',
    avatar: 'https://via.placeholder.com/40',
    status: 'active' as const,
  },
  {
    id: 2,
    name: '佐藤花子',
    email: 'sato@example.com',
    avatar: 'https://via.placeholder.com/40',
    status: 'inactive' as const,
  },
  {
    id: 3,
    name: '山田次郎',
    email: 'yamada@example.com',
    avatar: 'https://via.placeholder.com/40',
    status: 'active' as const,
  },
];

const meta: Meta<typeof UserList> = {
  title: 'Components/UserList',
  component: UserList,
  parameters: {
    layout: 'padded',
  },
};

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

// 正常なデータ読み込み
export const Default: Story = {
  args: {
    apiClient: {
      fetchUsers: fn().mockResolvedValue(mockUsers),
      updateUserStatus: fn().mockResolvedValue(undefined),
    },
    onUserClick: fn(),
  },
};

// 読み込み中の状態
export const Loading: Story = {
  args: {
    apiClient: {
      fetchUsers: fn().mockImplementation(
        () =>
          new Promise((resolve) =>
            setTimeout(() => resolve(mockUsers), 2000)
          )
      ),
      updateUserStatus: fn().mockResolvedValue(undefined),
    },
  },
};

// エラー状態
export const Error: Story = {
  args: {
    apiClient: {
      fetchUsers: fn().mockRejectedValue(
        new Error('API Error')
      ),
      updateUserStatus: fn().mockResolvedValue(undefined),
    },
  },
};

// インタラクションテスト
export const InteractionTest: Story = {
  args: {
    apiClient: {
      fetchUsers: fn().mockResolvedValue(mockUsers),
      updateUserStatus: fn().mockImplementation(
        () =>
          new Promise((resolve) =>
            setTimeout(resolve, 1000)
          )
      ),
    },
    onUserClick: fn(),
  },
  play: async ({ canvasElement, args }) => {
    const canvas = within(canvasElement);

    // データが読み込まれるまで待機
    await waitFor(() => {
      expect(
        canvas.getByText('田中太郎')
      ).toBeInTheDocument();
    });

    // ユーザークリックのテスト
    const userItem = canvas
      .getByText('田中太郎')
      .closest('div');
    await userEvent.click(userItem!);

    await expect(args.onUserClick).toHaveBeenCalledWith(
      mockUsers[0]
    );

    // ステータス変更のテスト
    const statusButton =
      canvas.getAllByText('ステータス変更')[0];
    await userEvent.click(statusButton);

    // 更新中の表示確認
    await expect(
      canvas.getByText('更新中...')
    ).toBeInTheDocument();

    // API呼び出しの確認
    await expect(
      args.apiClient.updateUserStatus
    ).toHaveBeenCalledWith(1, 'inactive');

    // 更新完了の確認
    await waitFor(
      () => {
        expect(
          canvas.queryByText('更新中...')
        ).not.toBeInTheDocument();
      },
      { timeout: 2000 }
    );
  },
};

// ステータス更新エラーのテスト
export const StatusUpdateError: Story = {
  args: {
    apiClient: {
      fetchUsers: fn().mockResolvedValue(mockUsers),
      updateUserStatus: fn().mockRejectedValue(
        new Error('Update failed')
      ),
    },
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    await waitFor(() => {
      expect(
        canvas.getByText('田中太郎')
      ).toBeInTheDocument();
    });

    const statusButton =
      canvas.getAllByText('ステータス変更')[0];
    await userEvent.click(statusButton);

    // エラーメッセージの表示確認
    await waitFor(() => {
      expect(
        canvas.getByText('ステータスの更新に失敗しました')
      ).toBeInTheDocument();
    });
  },
};

以上の具体例から分かるように、Storybook を活用することで、基本的なコンポーネントから複雑な状態を持つコンポーネントまで、包括的かつ効率的にテストすることが可能です。

重要なのは、各コンポーネントの特性に応じてテスト戦略を調整し、Visual Testing、Interaction Testing、そしてアクセシビリティテストを適切に組み合わせることです。

まとめ

Storybook を活用したコンポーネントテスト戦略について、基礎から応用まで段階的に解説してきました。従来のテスト手法の限界を超えて、視覚的品質と機能的品質の両方を効率的に保証する新しいアプローチをご理解いただけたでしょうか。

本記事で解決できる課題

品質保証の自動化 Storybook を中心としたテスト戦略により、手動での品質確認作業が大幅に削減されます。Visual Regression Testing により、意図しないデザイン変更を自動検知し、Interaction Testing によってユーザー操作の品質を継続的に保証できるようになります。

チーム間の品質基準統一 共通のテスト環境とレポート基盤により、開発者間での品質基準のばらつきが解消されます。新しいメンバーも、既存の Story を参考にしながら一貫した品質でコンポーネントを開発できるでしょう。

開発効率の向上 CI/CD パイプラインとの統合により、プルリクエストの段階で自動的に品質チェックが実行されます。これにより、レビューアーはロジックや設計により集中でき、全体的な開発速度が向上します。

導入時のポイント

段階的な導入 既存プロジェクトへの導入は段階的に行うことが重要です。まず主要なコンポーネントから Story 化を始め、チームが慣れてきたところで自動テストの統合を進めましょう。

適切なテスト粒度の選択 すべてのコンポーネントに対して同じレベルのテストを適用する必要はありません。使用頻度や重要度に応じて、テストの深さを調整することで効率的な品質保証を実現できます。

継続的な改善 テスト戦略は一度構築して終わりではありません。プロジェクトの成長やチームの変化に合わせて、継続的に見直しと改善を行うことが成功の鍵となります。

今後の発展性

Storybook エコシステムは現在も活発に発展を続けています。新しいアドオンや統合ツールが継続的にリリースされており、今後はさらに高度な自動化や AI を活用した品質チェックなども期待されます。

パフォーマンステストとの統合 Web Vitals の監視や、レンダリングパフォーマンスの継続的な測定など、品質の定義がより包括的になっていくでしょう。

デザイントークンとの連携 デザインシステムとの統合がさらに進み、デザイナーと開発者の協力体制がより密接になることが予想されます。

Storybook を活用したコンポーネントテスト戦略は、モダンなフロントエンド開発における品質保証の新しい標準となりつつあります。皆さんのプロジェクトでも、ぜひこの戦略を活用して、より高品質で保守性の高いコンポーネントライブラリを構築してください。

継続的な学習と実践を通じて、チーム全体の開発力向上につながることを心より願っています。

関連リンク

公式ドキュメント

開発ツール

コミュニティリソース

参考記事・チュートリアル