React Testing は何をしますか?
React コンポーネントのテストに関する包括的なパターン集です。振る舞いに焦点を当てたコンポーネントテスト、カスタムフックのテスト、アクセシビリティ検証、ネットワークレベルのモックを扱います。
いつ Activate するか
- React コンポーネント、カスタムフック、ページのテストを書くとき
- レガシーな未テストコンポーネントにテストカバレッジを追加するとき
- Enzyme やクラスコンポーネント時代のパターンから React Testing Library へ移行するとき
- 新しい React プロジェクトで Vitest または Jest をセットアップするとき
- テスト内で HTTP リクエストをモックするとき
- アクセシビリティ違反を検証するとき
- どのテストを RTL で書くべきか、Playwright コンポーネントテストで書くべきか、あるいは完全な E2E で書くべきかを判断するとき
核となる原則
ユーザーが見て操作するものをテストし、実装の詳細はテストしないこと。
テストは以下を行うべきです:
- プロダクションと同じプロバイダーでコンポーネントをレンダリングする
- アクセシブルなクエリ(role, label)と
userEventを使って操作する - 表示される出力と観測可能な副作用(コールバックの呼び出し、リクエストの送信)を検証する
テストは以下を行うべきではありません:
- コンポーネントの状態、子コンポーネントに渡される props、呼び出されたフックを検査する
- React 自体やフレームワークのフックをモックする
- レンダリング回数や、ユーザーに影響を与えない DOM 構造を検証する
ライブラリの選択
| ランナー | 使用場面 | 備考 |
|---|---|---|
| Vitest | Vite, Remix, モダンなセットアップ | 高速、ネイティブ ESM、Jest 互換 API |
| Jest | Next.js, CRA, 既存のリポジトリ | 多くの React プロジェクトでのデフォルト |
| Playwright コンポーネントテスト | 実際のブラウザエンジンが必要な場合 | JSDOM に不足している機能が必要なときに使用 |
| Cypress コンポーネントテスト | 実際のブラウザ、かつ Cypress を既に使用している場合 | Playwright CT の代替 |
1 つを選んでください。明確なレーン分離がない限り、同じリポジトリ内で RTL + Vitest と Playwright CT を併用しないでください。
クエリ優先度
React Testing Library は 3 つの階層でクエリを提供します。上位から使用してください:
- すべてのユーザーがアクセス可能:
getByRole,getByLabelText,getByPlaceholderText,getByText,getByDisplayValue - セマンティック:
getByAltText,getByTitle - テスト ID(最終手段):
getByTestId
// ベスト
screen.getByRole("button", { name: /save/i });
// 入力項目には OK
screen.getByLabelText("Email");
// 最終手段
screen.getByTestId("save-btn");
バリエーション:
getBy*— 見つからない場合はエラーをスローqueryBy*—nullを返す(「存在しないことの表明」に使用)findBy*— 非同期、Promise を返す(非同期処理の後に表示される要素に使用)
userEvent によるユーザー操作
import userEvent from "@testing-library/user-event";
test("フォームを送信する", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<UserForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText("Email"), "[email protected]");
await user.click(screen.getByRole("button", { name: /save/i }));
expect(onSubmit).toHaveBeenCalledWith({ email: "[email protected]" });
});
userEventの呼び出しは常にawaitすること- テストごとに
userEvent.setup()を 1 回呼び出し、返されたuserを使い回すこと userEventは実際のブラウザの一連の操作をシミュレートします。fireEventは単一の合成イベントをディスパッチするだけです。userEventを優先してください
非同期パターン
// 非同期処理の後に現れる要素
await expect(screen.findByText("Loaded")).resolves.toBeInTheDocument();
// 副作用の検証
await waitFor(() => expect(saveSpy).toHaveBeenCalled());
// 消えるべき要素
await waitForElementToBeRemoved(() => screen.queryByText("Loading"));
setTimeout + アサーションは絶対に使用しないでください。不安定になります。上記のマッチャーを使用してください。
MSW によるネットワークモック
Mock Service Worker はネットワーク層でモックを行います。コンポーネント、フック、フェッチライブラリはすべてプロダクションとまったく同じように動作します。
セットアップ
// test/setup.ts
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/users/:id", ({ params }) =>
HttpResponse.json({ id: params.id, name: "Alice" }),
),
http.post("/api/users", async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: "new-id", ...body }, { status: 201 });
}),
];
export const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
onUnhandledRequest: "error" を設定すると、モックされていないリクエストがあった場合にテストが確実に失敗します。沈黙のパスはレッドよりも悪質です。
テストごとの上書き
test("500 エラーをレンダリングする", async () => {
server.use(
http.get("/api/users/:id", () => new HttpResponse(null, { status: 500 })),
);
render(<UserPage id="1" />);
await expect(screen.findByText(/something went wrong/i)).resolves.toBeInTheDocument();
});
プロバイダーのラッピング
プロバイダーは test-utils.tsx で一度ラップします:
// test-utils.tsx
import { render, RenderOptions } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
export function renderWithProviders(
ui: React.ReactElement,
options?: RenderOptions,
) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={lightTheme}>
<MemoryRouter>{ui}</MemoryRouter>
</ThemeProvider>
</QueryClientProvider>,
options,
);
}
export * from "@testing-library/react";
そして、すべてのテストファイルで import { renderWithProviders, screen } from "test-utils" を使用します。
カスタムフックのテスト
import { renderHook, act } from "@testing-library/react";
test("useCounter がインクリメントとデクリメントを行う", () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => result.current.increment());
expect(result.current.count).toBe(1);
act(() => result.current.decrement());
expect(result.current.count).toBe(0);
});
test("useCounter が初期値を受け入れる", () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test("useUser がユーザーデータを取得する", async () => {
// QueryClient はラッパーの外側でテストごとに 1 回インスタンス化し、再レンダリングをまたいでキャッシュ状態を維持できるようにします。
// ラッパークロージャ内で作成すると、レンダリングのたびにキャッシュがリセットされ、テストが不安定になります。
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useUser("1"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({ id: "1", name: "Alice" });
});
- 状態を変更する呼び出しは
actでラップすること - フックのパブリック API のみをテストすること
- コンテキストを使用するフックの場合は
wrapperを渡すこと
アクセシビリティ検証
import { axe, toHaveNoViolations } from "jest-axe"; // または vitest-axe
expect.extend(toHaveNoViolations);
test("UserCard にアクセシビリティ違反がないこと", async () => {
const { container } = render(<UserCard user={mockUser} />);
await expect(axe(container)).resolves.toHaveNoViolations();
});
すべてのインタラクティブなコンポーネントのコンポーネントテストで axe を実行してください。以下を検出します:
- フォーム入力のラベル欠落
- 無効な ARIA の使用
- 不十分な色のコントラスト(限定的 — JSDOM には実際の CSS エンジンがないため、インラインスタイルのみで機能します。視覚的なコントラストは Playwright の担当です)
- 画像の alt テキスト欠落
- 見出し順序の違反
関連リンク: skills/accessibility/SKILL.md より広範なアクセシビリティテストのプレイブックについて。
スナップショットテストを使用すべきでない場合
レンダリング出力のスナップショットは:
- スタイル変更のたびに壊れる
- レビュー時に機械的に承認される
- 実装の詳細(DOM 構造)をテストし、振る舞いをテストしない
許容されるスナップショットの使用例:
- 純粋なデータシリアライズ関数(
formatInvoice(invoice)-> 安定した文字列) - 生成された設定ファイル(例:webpack 設定出力)
コンポーネントのビジュアルリグレッションには、Playwright/Cypress のスクリーンショット、または Percy/Chromatic を使用してください。DOM 文字列ではなく、実際のビジュアル差分を確認します。
Playwright / Cypress を使うべき時
JSDOM(Vitest/Jest が使用)では以下ができません:
- 実際のレイアウトのレンダリング(flexbox、grid、ビューポートクエリ)
- ネイティブブラウザアニメーション、CSS トランジションの実行
- スクロール動作、ドラッグ&ドロップ、クリップボードからの貼り付けのテスト
- iframe、ポップアップ、ダウンロード、クロスオリジンフローの処理
- 完全な DevTools サポートを備えた制御された環境での実際のネットワークの実行
これらには、Playwright コンポーネントテスト(実際のブラウザでのコンポーネントテスト)または完全な E2E を使用してください。e2e-testing skill を参照してください。
判断基準:
- フック、プレゼンテーショナルコンポーネント、ロジックを含むフォーム -> RTL
- レイアウトが重要、または JSDOM にないブラウザ API を使用するコンポーネント -> Playwright CT
- 複数ページにわたる完全なユーザーフロー -> Playwright/Cypress E2E
カバレッジ目標
| レイヤー | 目標 |
|---|---|
| 純粋なユーティリティ | >=90% |
| カスタムフック | >=85% |
| プレゼンテーショナルコンポーネント | >=80% — 行数ではなく動作 |
| コンテナコンポーネント | >=70% — ゴールデンパス + エラー状態 |
| ページ | E2E で別途カバー。最低限スモークテスト |
vitest.config.ts / jest.config.js で設定:
// vitest.config.ts
test: {
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
thresholds: {
lines: 80,
functions: 80,
branches: 70,
statements: 80,
},
},
}
アンチパターン
container.querySelector("...")— アクセシビリティクエリをバイパスし、実際のユーザーが失敗するようなテストを通してしまう- レンダリング回数を検証する — 実装の詳細
jest.mock("react", ...)— React をモックしてはいけません。代わりにコンポーネントをリファクタリングしてください- 子コンポーネントをデフォルトでモックする — 統合ではなく分離をテストすることになります。子コンポーネントに重い副作用がある場合のみモックしてください
act()の警告を無視する — これは実際のバグ(アンマウント後の状態更新、非同期ラッピングの欠落)を示しています- テスト間で可変状態を共有する — テスト順序が変わると不安定になります
it.skip()を削除してもパスするテスト — そのテストは実際に意図したとおりにアサーションを行っていません
TDD ワークフロー
RED -> 次の要件に対する失敗するテストを書く
GREEN -> テストを通すための最小限のコンポーネントコードを書く
REFACTOR -> コンポーネントを改善し、テストはグリーンのまま
REPEAT -> 次の要件へ
新しいコンポーネントの場合:
- コンポーネントの props の型とシグネチャを定義する
- 最も単純なケースの最初のテストを書く
- 正しい理由で失敗することを確認する
- テストを通すのに十分なコードだけを実装する
- 次のテストケースを追加する
- 3 つ目の類似テストがパターンを明らかにしたらリファクタリングする
テストコマンド
# Vitest
vitest # ウォッチモード
vitest run # 1 回実行
vitest run --coverage # カバレッジ付き
vitest run path/to/file.test.tsx # 単一ファイル
# Jest
jest --watch
jest --coverage
jest path/to/file.test.tsx
# CI モード
CI=true vitest run --coverage
関連
- ルール: rules/react/testing.md
- スキル: react-patterns, accessibility, e2e-testing, tdd-workflow
- エージェント:
react-reviewer(コードレビュー中にテスト品質をレビュー),tdd-guide(TDD プロセスを強制) - コマンド:
/react-test,/react-review
例
MSW と userEvent を使用したフォーム送信
test("ユーザーフォームを送信し、成功を表示する", async () => {
server.use(
http.post("/api/users", () =>
HttpResponse.json({ id: "1", name: "Alice" }, { status: 201 }),
),
);
const user = userEvent.setup();
renderWithProviders(<UserForm />);
await user.type(screen.getByLabelText("Name"), "Alice");
await user.type(screen.getByLabelText("Email"), "[email protected]");
await user.click(screen.getByRole("button", { name: /save/i }));
await expect(screen.findByText(/saved successfully/i)).resolves.toBeInTheDocument();
});
エラーバウンダリのテスト
function Broken() {
throw new Error("boom");
}
test("エラーバウンダリがフォールバックをレンダリングする", () => {
// 予期されるスローに対する React の console.error ノイズを抑制し、その後復元して
// スパイがテスト間でリークして他の場所で実際のエラーを隠さないようにします。
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
render(
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<Broken />
</ErrorBoundary>,
);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
} finally {
errorSpy.mockRestore();
}
});
Suspense バウンダリのテスト
test("ローディング後にコンテンツを表示する", async () => {
renderWithProviders(
<Suspense fallback={<div>Loading...</div>}>
<UserDetail id="1" />
</Suspense>,
);
expect(screen.getByText("Loading...")).toBeInTheDocument();
await expect(screen.findByText("Alice")).resolves.toBeInTheDocument();
});