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 });
}
};
まとめとベストプラクティス
効果的なテストを書くためのコツ
- 適切なクエリを選ぶ
- ユーザーの視点で要素を特定する
getByRole>getByLabelText>getByTestIdの順で優先
- 非同期処理を適切に扱う
waitForを使って状態変化を待機- タイムアウト値を適切に設定
- スコープを意識する
withinを使って検索範囲を限定- 大きなコンポーネントでは特に重要
- リアルなユーザー操作をシミュレート
userEventを使って実際の操作に近い動作を再現- キーボードナビゲーションも考慮
- 明確なアサーション
- 期待する結果を明確に記述
- エラーメッセージが理解しやすくなるよう工夫
これらのAPIを組み合わせることで、Storybookを単なるドキュメントツールから強力なテスト環境へと進化させることができます。継続的に品質の高いUIコンポーネントを開発していきましょう!
