testing-library について
React Testing Library を使う React 開発者向けのテストガイドです。アクセシビリティを優先したクエリやユーザー操作、非同期テストを扱い、MSW による API モック例も含みます。
主な機能
- 01アクセシビリティ優先のクエリ選択
- 02userEvent による操作の再現
- 03非同期 UI 変更のテスト
- 04MSW による API モック
- 05アクセシビリティ問題の確認
- 06フォーム・ダイアログ・リストのテストパターン
活用シーン
- 01フォーム送信と入力検証の確認
- 02非同期読み込みやダイアログのテスト
- 03API の成功・エラーレスポンスのモック
- 04コンポーネントのアクセシビリティ確認
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ jezweb/ claude-skills --skill testing-libraryインストール後、Claude Code で/testing-libraryを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
README.md.claude-plugin/plugin.jsonrules/testing-library-patterns.mdtemplates/msw-handlers.tssetup.ts詳細
React Testing Library
ステータス:本番利用可能 最終更新日:2026-02-06 バージョン:16.x User Event:14.x
クイックスタート
# Install with Vitest
pnpm add -D @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
# Or with Jest
pnpm add -D @testing-library/react @testing-library/user-event @testing-library/jest-dom
セットアップファイル(src/test/setup.ts)
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
// Cleanup after each test
afterEach(() => {
cleanup();
});
Vitest の設定
// vitest.config.ts
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
},
});
クエリの優先順位(アクセシビリティを最優先)
アクセシブルで堅牢なテストを作成するには、次の順番でクエリを使用します。
| 優先順位 | クエリ | 用途 |
|---|---|---|
| 1 | getByRole | ボタン、リンク、見出し、入力欄 |
| 2 | getByLabelText | ラベル付きのフォーム入力欄 |
| 3 | getByPlaceholderText | 表示ラベルのない入力欄 |
| 4 | getByText | インタラクティブではないテキストコンテンツ |
| 5 | getByTestId | 最終手段のみ |
例
import { render, screen } from '@testing-library/react';
// ✅ GOOD - semantic role queries
screen.getByRole('button', { name: /submit/i });
screen.getByRole('heading', { level: 1 });
screen.getByRole('textbox', { name: /email/i });
screen.getByRole('link', { name: /learn more/i });
// ✅ GOOD - label-based queries for forms
screen.getByLabelText(/email address/i);
// ⚠️ OK - when no better option
screen.getByText(/welcome to our app/i);
// ❌ AVOID - not accessible, brittle
screen.getByTestId('submit-button');
クエリのバリエーション
| バリエーション | 戻り値 | 例外 | 用途 |
|---|---|---|---|
getBy | 要素 | 発生する | 要素が今存在する |
queryBy | 要素または null | 発生しない | 要素が存在しない可能性がある |
findBy | Promise | 発生する | 非同期で後から表示される |
getAllBy | Element[] | 発生する | 複数の要素 |
queryAllBy | Element[] | 発生しない | 複数または該当なし |
findAllBy | Promise<Element[]> | 発生する | 非同期で複数の要素 |
使い分け
// Element exists immediately
const button = screen.getByRole('button');
// Check element doesn't exist
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
// Wait for async element to appear
const modal = await screen.findByRole('dialog');
// Multiple elements
const items = screen.getAllByRole('listitem');
User Event(実際に近いインタラクション)
fireEvent ではなく、必ず userEvent を使用してください。実際のユーザーの操作をシミュレートします。
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
describe('Form', () => {
it('submits form data', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
// 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(onSubmit).toHaveBeenCalledWith({
email: '[email protected]',
password: 'secret123',
});
});
});
よく使うユーザーイベント
const user = userEvent.setup();
// Clicking
await user.click(element);
await user.dblClick(element);
await user.tripleClick(element); // Select all text
// Typing
await user.type(input, 'hello world');
await user.clear(input);
await user.type(input, '{Enter}'); // Special keys
// Keyboard
await user.keyboard('{Shift>}A{/Shift}'); // Shift+A
await user.tab(); // Tab navigation
// Selection
await user.selectOptions(select, ['option1', 'option2']);
// Hover
await user.hover(element);
await user.unhover(element);
// Clipboard
await user.copy();
await user.paste();
非同期テスト
findBy - 要素が表示されるまで待機
it('shows loading then content', async () => {
render(<AsyncComponent />);
// Shows loading initially
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Wait for content to appear (auto-retries)
const content = await screen.findByText(/data loaded/i);
expect(content).toBeInTheDocument();
});
waitFor - 条件が満たされるまで待機
import { waitFor } from '@testing-library/react';
it('updates count after click', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole('button', { name: /increment/i }));
// Wait for state update
await waitFor(() => {
expect(screen.getByText(/count: 1/i)).toBeInTheDocument();
});
});
waitForElementToBeRemoved
import { waitForElementToBeRemoved } from '@testing-library/react';
it('hides modal after close', async () => {
const user = userEvent.setup();
render(<ModalComponent />);
await user.click(screen.getByRole('button', { name: /close/i }));
// Wait for modal to disappear
await waitForElementToBeRemoved(() => screen.queryByRole('dialog'));
});
MSW 連携(API のモック)
Mock Service Worker を使用して、ネットワークレベルで API 呼び出しをモックします。
pnpm add -D msw
セットアップ(src/test/mocks/handlers.ts)
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/user', () => {
return HttpResponse.json({
id: 1,
name: 'Test User',
email: '[email protected]',
});
}),
http.post('/api/login', async ({ request }) => {
const body = await request.json();
if (body.password === 'correct') {
return HttpResponse.json({ token: 'abc123' });
}
return HttpResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
}),
];
セットアップ(src/test/mocks/server.ts)
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
テストのセットアップ
// src/test/setup.ts
import { server } from './mocks/server';
import { beforeAll, afterEach, afterAll } from 'vitest';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
テストでの使用
import { server } from '../test/mocks/server';
import { http, HttpResponse } from 'msw';
it('handles API error', async () => {
// Override handler for this test
server.use(
http.get('/api/user', () => {
return HttpResponse.json(
{ error: 'Server error' },
{ status: 500 }
);
})
);
render(<UserProfile />);
await screen.findByText(/error loading user/i);
});
アクセシビリティテスト
A11y 違反の確認
pnpm add -D @axe-core/react
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
it('has no accessibility violations', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
ロールベースのクエリは A11y テストです
getByRole を使用すると、暗黙的にアクセシビリティをテストできます。
// This passes only if button is properly accessible
screen.getByRole('button', { name: /submit/i });
// Fails if:
// - Element isn't a button or role="button"
// - Accessible name doesn't match
// - Element is hidden from accessibility tree
テストパターン
フォーム
it('validates required fields', async () => {
const user = userEvent.setup();
render(<ContactForm />);
// Submit without filling required fields
await user.click(screen.getByRole('button', { name: /submit/i }));
// Check for validation errors
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
expect(screen.getByText(/message is required/i)).toBeInTheDocument();
});
モーダル/ダイアログ
it('opens and closes modal', async () => {
const user = userEvent.setup();
render(<ModalTrigger />);
// Modal not visible initially
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
// Open modal
await user.click(screen.getByRole('button', { name: /open/i }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
// Close modal
await user.click(screen.getByRole('button', { name: /close/i }));
await waitForElementToBeRemoved(() => screen.queryByRole('dialog'));
});
リスト
it('renders list items', () => {
render(<TodoList items={['Buy milk', 'Walk dog']} />);
const items = screen.getAllByRole('listitem');
expect(items).toHaveLength(2);
expect(items[0]).toHaveTextContent('Buy milk');
});
よく使うマッチャー(jest-dom)
// Presence
expect(element).toBeInTheDocument();
expect(element).toBeVisible();
expect(element).toBeEmptyDOMElement();
// State
expect(button).toBeEnabled();
expect(button).toBeDisabled();
expect(checkbox).toBeChecked();
expect(input).toBeRequired();
// Content
expect(element).toHaveTextContent(/hello/i);
expect(element).toHaveValue('test');
expect(element).toHaveAttribute('href', '/about');
// Styles
expect(element).toHaveClass('active');
expect(element).toHaveStyle({ color: 'red' });
// Focus
expect(input).toHaveFocus();
デバッグ
screen.debug()
it('debugs rendering', () => {
render(<MyComponent />);
// Print entire DOM
screen.debug();
// Print specific element
screen.debug(screen.getByRole('button'));
});
logRoles
import { logRoles } from '@testing-library/react';
it('shows available roles', () => {
const { container } = render(<MyComponent />);
logRoles(container);
});
よくある間違い
非同期処理で getBy を使用する
// ❌ WRONG - fails if element appears async
const modal = screen.getByRole('dialog');
// ✅ CORRECT - waits for element
const modal = await screen.findByRole('dialog');
ユーザーイベントを await しない
// ❌ WRONG - race condition
user.click(button);
expect(result).toBeInTheDocument();
// ✅ CORRECT - await the interaction
await user.click(button);
expect(result).toBeInTheDocument();
container.querySelector を使用する
// ❌ WRONG - not accessible, brittle
const button = container.querySelector('.submit-btn');
// ✅ CORRECT - accessible query
const button = screen.getByRole('button', { name: /submit/i });
関連項目
vitestスキル - テストランナーの設定testing-patternsスキル - 一般的なテストパターン- 公式ドキュメント:https://testing-library.com/docs/react-testing-library/intro
コメント
Markdown 対応