Storybookを用いたテストでよく使われる機能について

Storybookは単なるコンポーネントカタログを超えて、強力なテスト環境としても活用できます。今回は、Storybookのテストで使用する以下の5つの重要なAPIについて、実践的な使い方を交えながら詳しく解説していきます。

  • expect
  • userEvent
  • waitFor
  • within
  • screen

はじめに:Storybookのテストとは

Storybookのインタラクションテストは、Play functionを使ってユーザーの操作をシミュレートし、コンポーネントの動作を自動的に検証する機能です。これにより、UIコンポーネントの品質を向上させ、回帰バグを防ぐことができます。

// 基本的なPlay functionの例
export const Interactive = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    const button = canvas.getByRole('button');
    
    await userEvent.click(button);
    await expect(canvas.getByText('クリックされました')).toBeVisible();
  }
};

1. screen:DOMへの入り口

screenは、Testing Libraryから提供されるグローバルオブジェクトで、現在のドキュメント全体からDOM要素を検索できます。

基本的な使い方

import { screen } from '@storybook/testing-library';

export const ScreenExample = {
  play: async () => {
    // テキストで要素を検索
    const heading = screen.getByText('ようこそ');
    
    // ロールで要素を検索
    const button = screen.getByRole('button', { name: '送信' });
    
    // プレースホルダーで要素を検索
    const input = screen.getByPlaceholderText('メールアドレス');
    
    await userEvent.type(input, 'test@example.com');
    await userEvent.click(button);
  }
};

screenで使える主要なクエリメソッド

  • getByText() – テキスト内容で検索
  • getByRole() – ARIA ロールで検索
  • getByLabelText() – ラベルテキストで検索
  • getByPlaceholderText() – プレースホルダーで検索
  • getByTestId() – data-testid属性で検索
  • queryBy*() – 要素が存在しない場合にnullを返す
  • findBy*() – 非同期で要素を待機

2. within:スコープを限定した検索

withinは、特定の要素の内部に検索範囲を限定するために使用します。大きなコンポーネントやページで、特定の領域内の要素のみを対象にしたい場合に便利です。

基本的な使い方

import { within } from '@storybook/testing-library';

export const WithinExample = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    
    // ヘッダー領域内の要素を検索
    const header = canvas.getByRole('banner');
    const headerScope = within(header);
    const logo = headerScope.getByAltText('ロゴ');
    
    // フォーム領域内の要素を検索
    const form = canvas.getByRole('form');
    const formScope = within(form);
    const emailInput = formScope.getByLabelText('メール');
    const submitButton = formScope.getByRole('button', { name: '送信' });
    
    await userEvent.type(emailInput, 'user@example.com');
    await userEvent.click(submitButton);
  }
};

withinが特に有効なケース

// モーダルダイアログ内の操作
export const ModalInteraction = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    
    // モーダルを開く
    await userEvent.click(canvas.getByText('モーダルを開く'));
    
    // モーダル内でのみ検索
    const modal = canvas.getByRole('dialog');
    const modalScope = within(modal);
    
    await userEvent.type(modalScope.getByLabelText('名前'), '太郎');
    await userEvent.click(modalScope.getByText('保存'));
  }
};

3. userEvent:リアルなユーザー操作

userEventは、実際のユーザーの操作をシミュレートするためのAPIです。単純なDOMイベントの発火ではなく、ブラウザでの実際のユーザー行動により近い動作を再現します。

基本的な操作

import { userEvent } from '@storybook/testing-library';

export const UserEventExample = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    
    // クリック操作
    await userEvent.click(canvas.getByRole('button'));
    
    // テキスト入力
    const input = canvas.getByRole('textbox');
    await userEvent.type(input, 'こんにちは世界');
    
    // テキストのクリア
    await userEvent.clear(input);
    
    // 選択肢の選択
    await userEvent.selectOptions(
      canvas.getByRole('combobox'),
      ['option1', 'option2']
    );
    
    // ファイルのアップロード
    const fileInput = canvas.getByLabelText('ファイル選択');
    const file = new File(['内容'], 'test.txt', { type: 'text/plain' });
    await userEvent.upload(fileInput, file);
  }
};

高度な操作パターン

// キーボードショートカットのテスト
export const KeyboardShortcuts = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    const editor = canvas.getByRole('textbox');
    
    await userEvent.type(editor, '太字にしたいテキスト');
    
    // テキストを全選択
    await userEvent.keyboard('{Control>}a{/Control}');
    
    // 太字のショートカット
    await userEvent.keyboard('{Control>}b{/Control}');
    
    await expect(editor).toHaveStyle('font-weight: bold');
  }
};

// ドラッグ&ドロップ操作
export const DragAndDrop = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    
    const draggable = canvas.getByText('ドラッグ可能');
    const dropzone = canvas.getByText('ドロップゾーン');
    
    await userEvent.hover(draggable);
    await userEvent.pointer({
      keys: '[MouseLeft>]',
      target: draggable
    });
    await userEvent.pointer({
      target: dropzone
    });
    await userEvent.pointer({
      keys: '[/MouseLeft]'
    });
  }
};

4. waitFor:非同期処理の待機

waitForは、非同期処理の完了や状態の変化を待機するために使用します。APIコールやアニメーション、遅延処理などがあるコンポーネントのテストには不可欠です。

基本的な使い方

import { waitFor } from '@storybook/testing-library';

export const AsyncExample = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    
    // API呼び出しをトリガー
    await userEvent.click(canvas.getByText('データ読み込み'));
    
    // ローディング表示の確認
    expect(canvas.getByText('読み込み中...')).toBeInTheDocument();
    
    // データの表示を待機(デフォルト5秒でタイムアウト)
    await waitFor(() => {
      expect(canvas.getByText('データが読み込まれました')).toBeVisible();
    });
    
    // ローディング表示の消失を確認
    expect(canvas.queryByText('読み込み中...')).not.toBeInTheDocument();
  }
};

カスタマイズオプション

// タイムアウトとインターバルの調整
export const CustomWaitFor = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    
    await userEvent.click(canvas.getByText('重い処理を開始'));
    
    // 10秒待機、500msごとにチェック
    await waitFor(
      () => {
        expect(canvas.getByText('処理完了')).toBeVisible();
      },
      {
        timeout: 10000,
        interval: 500
      }
    );
  }
};

// 複数条件の待機
export const MultipleConditions = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    
    await userEvent.click(canvas.getByText('フォーム送信'));
    
    await waitFor(() => {
      // 成功メッセージとリダイレクト処理の両方を待機
      expect(canvas.getByText('送信完了')).toBeVisible();
      expect(canvas.getByText('3秒後にリダイレクトします')).toBeVisible();
    });
  }
};

5. expect:アサーション(検証)

expectは、テストの結果を検証するためのアサーションライブラリです。Jest互換のAPIを提供し、様々な条件をチェックできます。

DOM要素の検証

export const ExpectExample = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    const button = canvas.getByRole('button');
    
    // 要素の存在確認
    expect(button).toBeInTheDocument();
    
    // 表示状態の確認
    expect(button).toBeVisible();
    
    // テキスト内容の確認
    expect(button).toHaveTextContent('クリック');
    
    // CSS属性の確認
    expect(button).toHaveClass('primary-button');
    expect(button).toHaveStyle('background-color: blue');
    
    // 属性の確認
    expect(button).toHaveAttribute('disabled', 'false');
    
    await userEvent.click(button);
    
    // 状態変化の確認
    expect(button).toHaveAttribute('aria-pressed', 'true');
  }
};

フォーム要素の検証

export const FormValidation = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    
    const emailInput = canvas.getByLabelText('メール');
    const passwordInput = canvas.getByLabelText('パスワード');
    
    // 初期状態の確認
    expect(emailInput).toHaveValue('');
    expect(emailInput).toBeRequired();
    expect(passwordInput).toHaveAttribute('type', 'password');
    
    // 入力後の確認
    await userEvent.type(emailInput, 'test@example.com');
    expect(emailInput).toHaveValue('test@example.com');
    
    // フォーカス状態の確認
    expect(emailInput).toHaveFocus();
    
    // バリデーションエラーの確認
    await userEvent.clear(emailInput);
    await userEvent.tab(); // フォーカスを移動
    
    await waitFor(() => {
      expect(canvas.getByText('メールアドレスは必須です')).toBeVisible();
    });
  }
};

カスタムマッチャーの活用

// アクセシビリティの検証
export const AccessibilityTest = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    const button = canvas.getByRole('button');
    
    // アクセシブルな名前の確認
    expect(button).toHaveAccessibleName('フォームを送信');
    
    // ARIAラベルの確認
    expect(button).toHaveAccessibleDescription('このボタンを押すとフォームが送信されます');
    
    // キーボードナビゲーション
    await userEvent.tab();
    expect(button).toHaveFocus();
    
    await userEvent.keyboard('{Enter}');
    // エンターキーでも動作することを確認
  }
};

実践的な組み合わせパターン

複雑なユーザーフローのテスト

export const ComplexUserFlow = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    
    // ステップ1: 初期状態の確認
    expect(canvas.getByText('ショッピングカート')).toBeVisible();
    expect(canvas.getByText('商品数: 0')).toBeVisible();
    
    // ステップ2: 商品をカートに追加
    const product = canvas.getByTestId('product-1');
    const productScope = within(product);
    
    await userEvent.click(productScope.getByText('カートに追加'));
    
    // ステップ3: 追加処理の完了を待機
    await waitFor(() => {
      expect(canvas.getByText('商品数: 1')).toBeVisible();
    });
    
    // ステップ4: カートを開いて内容確認
    await userEvent.click(canvas.getByText('カートを表示'));
    
    const cart = canvas.getByRole('dialog');
    const cartScope = within(cart);
    
    expect(cartScope.getByText('商品A')).toBeVisible();
    expect(cartScope.getByText('¥1,000')).toBeVisible();
    
    // ステップ5: チェックアウト
    await userEvent.click(cartScope.getByText('チェックアウト'));
    
    // ステップ6: 決済フォーム入力
    await userEvent.type(
      canvas.getByLabelText('カード番号'),
      '4111111111111111'
    );
    
    await userEvent.selectOptions(
      canvas.getByLabelText('有効期限(月)'),
      '12'
    );
    
    await userEvent.selectOptions(
      canvas.getByLabelText('有効期限(年)'),
      '2025'
    );
    
    // ステップ7: 注文確定
    await userEvent.click(canvas.getByText('注文を確定する'));
    
    // ステップ8: 完了画面の確認
    await waitFor(() => {
      expect(canvas.getByText('ご注文ありがとうございました')).toBeVisible();
    }, { timeout: 10000 });
  }
};

まとめとベストプラクティス

効果的なテストを書くためのコツ

  1. 適切なクエリを選ぶ
    • ユーザーの視点で要素を特定する
    • getByRole > getByLabelText > getByTestId の順で優先
  2. 非同期処理を適切に扱う
    • waitForを使って状態変化を待機
    • タイムアウト値を適切に設定
  3. スコープを意識する
    • withinを使って検索範囲を限定
    • 大きなコンポーネントでは特に重要
  4. リアルなユーザー操作をシミュレート
    • userEventを使って実際の操作に近い動作を再現
    • キーボードナビゲーションも考慮
  5. 明確なアサーション
    • 期待する結果を明確に記述
    • エラーメッセージが理解しやすくなるよう工夫

これらのAPIを組み合わせることで、Storybookを単なるドキュメントツールから強力なテスト環境へと進化させることができます。継続的に品質の高いUIコンポーネントを開発していきましょう!

コメントを残す