React Testing は何をしますか?
Reactコンポーネントのテストに関する包括的なパターン集。動作重視のコンポーネントテスト、カスタムフックのテスト、アクセシビリティアサーション、ネットワークレベルのモックを網羅します。
アクティベートするタイミング
- Reactコンポーネント、カスタムフック、またはページのテストを作成するとき
- レガシーでテストされていないコンポーネントにテストカバレッジを追加するとき
- Enzymeやクラスコンポーネント時代のパターンからReact Testing Libraryに移行するとき
- 新しいReactプロジェクトにVitestまたはJestをセットアップするとき
- テストでHTTPリクエストをモックするとき
- アクセシビリティ違反をアサートするとき
- どのテストをRTLで書くべきか、Playwright Component Testingで書くべきか、フルE2Eで書くべきかを判断するとき
中核となる原則
ユーザーが見て操作するものをテストし、実装の詳細をテストしないこと。
テストは以下のことを行うべき:
- コンポーネントを本番環境と同じプロバイダでレンダリングする
- アクセシブルなクエリ(role, label)と
userEventを使って操作する - 目に見える出力と観察可能な副作用(コールバックの実行、リクエストの送信)をアサートする
テストは以下のことを行うべきではない:
- コンポーネントの状態、子コンポーネントに渡されるprops、または呼び出されたフックを検査する
- React自体やフレームワークのフックをモックする
- ユーザーに影響を与えるものを超えた、レンダリング回数やDOM構造をアサートする
ライブラリの選択
| ランナー | 使用タイミング | 備考 |
|---|---|---|
| Vitest | Vite、Remix、モダンなセットアップ | 高速、ネイティブESM、Jest互換API |
| Jest | Next.js、CRA、確立されたリポジトリ | 多くのReactプロジェクトのデフォルト |
| Playwright Component Testing | 実際のブラウザエンジンが必要な場合 | JSDOMに必要な機能がない場合に使用 |
| Cypress Component Testing | 実際のブラウザ、Cypressを既に使用している場合 | Playwright CTの代替 |
ひとつを選んでください。明確なレーン分けがない限り、同じリポジトリ内で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()を一度呼び出し、返された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はネットワーク層でモックします。コンポーネント、フック、fetchライブラリはすべて本番環境とまったく同じように動作します。
セットアップ
// 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" />);
expect(await screen.findByText(/something went wrong/i)).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をラッパーの外側でテストごとに一度だけインスタンス化し、再レンダリングを跨いでキャッシュを維持する。
// ラッパーのクロージャ内で作成すると、レンダリングのたびにキャッシュがリセットされ、フレーキーなテストになる。
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} />);
expect(await axe(container)).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 Component Testing(実際のブラウザでのコンポーネントテスト)またはフル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 -> 次の要件へ
新しいコンポーネントの場合:
- コンポーネントのプロップタイプとシグネチャを定義する
- 最も単純なケースの最初のテストを書く
- 正しい理由で失敗することを確認する
- 通すのに十分なだけ実装する
- 次のテストケースを追加する
- 3番目の類似テストがパターンを明らかにしたらリファクタリングする
テストコマンド
# Vitest
vitest # ウォッチモード
vitest run # 一度だけ実行
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 }));
expect(await screen.findByText(/saved successfully/i)).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();
expect(await screen.findByText("Alice")).toBeInTheDocument();
});