react-testing について
Vitest と Testing Library を使った React コンポーネントのテストを扱うリファレンスです。React 開発者向けに、ユーザー操作や非同期更新、モックを含む動作中心のテスト方法をまとめています。
主な機能
- 01Vitest と Testing Library の設定
- 02アクセシブルな要素検索
- 03ユーザー操作のテスト
- 04非同期状態と更新のテスト
- 05関数とモジュールのモック
活用シーン
- 01レンダリングした UI でコンポーネントの動作を検証
- 02フォーム操作と送信を確認
- 03ローディングや非同期データをテスト
- 04API モジュールや関数の戻り値をモック
MCP 対応
React のテストドキュメントを取得する `mcp__documentation__fetch_docs` を案内しています。
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ claude-dev-suite/ claude-dev-suite --skill react-testingインストール後、Claude Code で/react-testingを入力して読み込まれたか確認します。
同梱ファイルadvanced.md
詳細
React のテスト
完全なリファレンス:MSW のセットアップ、フックのテスト、コンテキストのテスト、フォーム、アクセシビリティテスト、スナップショット、テストパターンについては、advanced.md を参照してください。
詳細な知識:包括的なドキュメントについては、technology:
react、topic:testingを指定してmcp__documentation__fetch_docsを使用してください。
Vitest + Testing Library のセットアップ
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test/setup.ts',
css: true,
},
});
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
});
基本的なコンポーネントのテスト
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
describe('Button', () => {
it('renders children', () => {
render(<Button onClick={() => {}}>Click me</Button>);
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});
it('calls onClick when clicked', async () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click me</Button>);
await userEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('is disabled when disabled prop is true', () => {
render(<Button onClick={() => {}} disabled>Click me</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
});
クエリメソッド
// Priority order (prefer accessible queries)
// 1. getByRole - accessible to everyone
screen.getByRole('button', { name: /submit/i });
screen.getByRole('textbox', { name: /email/i });
screen.getByRole('heading', { level: 1 });
// 2. getByLabelText - for form fields
screen.getByLabelText(/email address/i);
// 3. getByPlaceholderText
screen.getByPlaceholderText(/enter your email/i);
// 4. getByText - for non-interactive elements
screen.getByText(/welcome to our app/i);
// 5. getByDisplayValue - for input values
screen.getByDisplayValue(/[email protected]/i);
// 6. getByAltText - for images
screen.getByAltText(/user avatar/i);
// 7. getByTitle
screen.getByTitle(/close/i);
// 8. getByTestId - last resort
screen.getByTestId('custom-element');
// Query variants
screen.getByRole('button'); // Throws if not found
screen.queryByRole('button'); // Returns null if not found
screen.findByRole('button'); // Returns Promise, waits for element
screen.getAllByRole('button'); // Returns array, throws if none
screen.queryAllByRole('button'); // Returns array (possibly empty)
screen.findAllByRole('button'); // Returns Promise of array
ユーザーイベント
import userEvent from '@testing-library/user-event';
describe('Form', () => {
it('submits with user input', async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
// Type in inputs
await user.type(screen.getByLabelText(/email/i), '[email protected]');
await user.type(screen.getByLabelText(/password/i), 'secret123');
// Click submit
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(handleSubmit).toHaveBeenCalledWith({
email: '[email protected]',
password: 'secret123',
});
});
it('handles keyboard navigation', async () => {
const user = userEvent.setup();
render(<Form />);
// Tab through inputs
await user.tab();
expect(screen.getByLabelText(/email/i)).toHaveFocus();
await user.tab();
expect(screen.getByLabelText(/password/i)).toHaveFocus();
// Type and submit with Enter
await user.type(screen.getByLabelText(/password/i), 'secret{Enter}');
});
it('handles select and checkbox', async () => {
const user = userEvent.setup();
render(<SettingsForm />);
// Select option
await user.selectOptions(screen.getByRole('combobox'), ['dark']);
// Toggle checkbox
await user.click(screen.getByRole('checkbox', { name: /notifications/i }));
expect(screen.getByRole('checkbox')).toBeChecked();
});
});
非同期テスト
describe('UserProfile', () => {
it('shows loading then user data', async () => {
render(<UserProfile userId="123" />);
// Initially shows loading
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Wait for data to load
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
// Loading is gone
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
});
// Using findBy (combines getBy + waitFor)
it('loads and displays items', async () => {
render(<ItemList />);
// findBy waits for element
const items = await screen.findAllByRole('listitem');
expect(items).toHaveLength(3);
});
// waitForElementToBeRemoved
it('removes loading indicator', async () => {
render(<DataLoader />);
await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));
expect(screen.getByText('Data loaded')).toBeInTheDocument();
});
モック
モック関数
import { vi } from 'vitest';
const mockFn = vi.fn();
mockFn.mockReturnValue('default');
mockFn.mockReturnValueOnce('first call');
mockFn.mockImplementation((x) => x * 2);
mockFn.mockResolvedValue({ data: [] });
mockFn.mockRejectedValue(new Error('Failed'));
// Assertions
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockFn).toHaveBeenLastCalledWith('last arg');
モジュールのモック
// Mock a module
vi.mock('@/lib/api', () => ({
fetchUsers: vi.fn(() => Promise.resolve([{ id: 1, name: 'John' }])),
}));
// Import after mocking
import { fetchUsers } from '@/lib/api';
it('fetches users', async () => {
render(<UserList />);
expect(fetchUsers).toHaveBeenCalled();
await screen.findByText('John');
});
よくある落とし穴
| 問題 | 原因 | 解決策 |
|---|---|---|
| テストで要素が見つからない | 要素が非同期でレンダリングされる | findBy または waitFor を使用する |
| 状態が更新されない | act() が不足している | userEvent を使用する(act を処理します) |
| テスト同士が影響し合う | 状態が共有されている | afterEach でクリーンアップする |
| 不安定なテスト | 競合状態 | 適切な非同期パターンを使用する |
ベストプラクティス
- 実装ではなく動作をテストする
- アクセシブルなクエリ(getByRole)を使用する
- fireEvent より userEvent を使用する
- エラー状態をテストする
- API のモックには MSW を使用する
- 実装の詳細はテストしない
- サードパーティライブラリはテストしない
- スナップショットを使いすぎない
このスキルを使用しない場合
- エンドツーエンドテスト - 完全な E2E フローには Playwright スキルを使用する
- バックエンドのテスト - フレームワーク固有のテストスキルを使用する
- パフォーマンステスト - 専用のパフォーマンステストツールを使用する
- ビジュアルリグレッションテスト - Percy や Chromatic などのツールを使用する
アンチパターン
| アンチパターン | 問題 | 解決策 |
|---|---|---|
| 実装の詳細をテストする | テストが壊れやすい | ユーザーに見える動作をテストする |
| 最初に getByTestId を使う | アクセシビリティをテストできない | getByRole、getByLabelText を優先する |
| userEvent の代わりに fireEvent を使う | 実際のユーザー操作をシミュレートできない | 現実的なテストには userEvent を使用する |
| 非同期更新を待たない | テストが不安定になる | findBy または waitFor を使用する |
| すべてをスナップショットテストする | 保守が難しい | 安定した UI に限り、控えめに使用する |
| サードパーティライブラリをテストする | 労力が無駄になる | ライブラリのテストを信頼し、連携部分をテストする |
| エラー状態をテストしない | エッジケースを見逃す | 読み込み中、エラー、空の状態をテストする |
| シャローレンダリング | 統合上の問題を見逃す | 完全なレンダリングを使用する |
問題の簡易解決
| 問題 | 考えられる原因 | 修正方法 |
|---|---|---|
| 要素が見つからない | クエリのタイミング、誤ったクエリ | 非同期処理には findBy を使用するか、クエリを確認する |
| テストがタイムアウトする | 非同期処理が完了しない | waitFor のタイムアウトを確認し、非同期コードを修正する |
| Act の警告 | act の外で状態が更新されている | userEvent を使用するか、act() で囲む |
| 不安定なテスト | 競合状態、タイミング | 適切な非同期クエリ(findBy、waitFor)を使用する |
| フックをテストできない | 実装をテストしている | コンポーネントに切り出すか、renderHook を使用する |
| モックが機能しない | インポート後にモックしている | コンポーネントをインポートする前にモックする |
| テスト同士が影響し合う | 状態が共有されている | afterEach でクリーンアップする |
リファレンスドキュメント
- Testing Library
- Vitest
- MSW
- MCP:
mcp__documentation__fetch_docs→ technology:react、topic:testing
コメント
Markdown 対応