React Testing Library(以下RTL)は、Reactコンポーネントを「ユーザーが実際にどう触るか」という観点で検証するためのデファクトスタンダードです。本記事では Vitest をメインに、MSW、カスタムフック、カスタムレンダラ、フォーム、ルーティング、Context、Playwright 連携、CI まで、実プロジェクトでそのままコピペできるコード中心で総整理します。Jest 利用者向けの差分も併記します。
対象読者は、コンポーネントは書けるがテスト戦略に迷っている現役Webエンジニアです。screen.getByRole と userEvent を中心に、実装詳細ではなく「ユーザー観察可能な振る舞い」をテストする方針を一貫して採用します。
- RTLの設計思想とテスト戦略の地図
- セットアップ:Vitest + RTL + jsdom(2026年現行版)
- 最初のテスト:render と screen を使いこなす
- 6種類のクエリと使い分け:getBy / queryBy / findBy × 単数 / All
- クエリの優先順位:byRole を最優先にする理由
- userEvent vs fireEvent:必ず userEvent を選ぶ
- 非同期テスト:waitFor / findBy / act の正しい使い分け
- カスタムレンダラ:Provider群を1か所で巻く
- カスタムフックを renderHook で単体テストする
- MSW でAPI層を一括モックする(2.x系)
- APIモック付きコンポーネントテストの実例
- フォームテスト:バリデーション・submit・サーバーエラー
- ルーティングテスト:MemoryRouter で完結させる
- Context Provider のテスト
- エラーバウンダリのテスト
- モック戦略:何をモックし、何をしないか
- snapshot テストとの付き合い方
- カバレッジ・CI 連携・Storybook 統合
- E2E と Visual Regression:Playwright との分業
- パフォーマンスと debug のコツ
- よくある質問(FAQ)
- まとめ:RTL を使いこなすための7原則
RTLの設計思想とテスト戦略の地図
RTL が他のテストツールと違うのは、「内部state」「コンポーネントのprops」「実装の詳細」ではなく、「DOMに何が見えているか」「ユーザーが何を操作できるか」だけを観察対象にする点です。これは Kent C. Dodds 氏の「The more your tests resemble the way your software is used, the more confidence they can give you.」という思想に基づきます。
| テスト種別 | 主目的 | 主ツール | 速度 | 本記事の扱い |
|---|---|---|---|---|
| ユニットテスト | 純関数・カスタムフック単体 | Vitest + RTL renderHook | ◎ | 中心 |
| コンポーネントテスト | 1コンポーネントの振る舞い | RTL + userEvent | ◎ | 中心 |
| 統合テスト | 複数コンポ+API+Router | RTL + MSW + MemoryRouter | ○ | 中心 |
| E2Eテスト | 実ブラウザ全体動作 | Playwright | △ | 連携のみ |
| Visual Regression | UIの見た目差分 | Playwright + toHaveScreenshot | △ | 連携のみ |
「テストピラミッド」の下層(ユニット〜統合)を厚く、E2Eは数本に絞る方針が、CIコストと信頼性のバランスとして現実的です。本記事では、その下層を RTL で堅牢に組み立てる方法に集中します。
セットアップ:Vitest + RTL + jsdom(2026年現行版)
Vite ベースのプロジェクトなら Vitest が圧倒的に高速かつ設定がシンプルです。まず依存をインストールします。
依存関係のインストール
npm i -D vitest @testing-library/react @testing-library/jest-dom
@testing-library/user-event jsdom @vitest/coverage-v8
vitest.config.ts の最小構成
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
css: false,
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
exclude: ["**/*.config.*", "**/dist/**", "**/test/**"],
thresholds: { lines: 80, functions: 80, branches: 75, statements: 80 },
},
},
});
setupファイル(jest-dom拡張 + cleanup)
// src/test/setup.ts
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
cleanup();
});
Vitest は globals: true 時のみ afterEach が自動cleanupされる挙動があるため、明示的に書いておくと挙動が安定します。
Jest を使う場合のセットアップ(参考)
// jest.config.ts
import type { Config } from "jest";
const config: Config = {
testEnvironment: "jsdom",
setupFilesAfterEach: ["<rootDir>/src/test/setup.ts"],
moduleNameMapper: {
"\.(css|less|scss)$": "identity-obj-proxy",
},
transform: {
"^.+\.(t|j)sx?$": ["@swc/jest"],
},
};
export default config;
Vitest と Jest の主な差分は「import パス」「vi vs jest」「設定方式」のみで、テスト本文は ほぼそのまま動きます。本記事は以降 Vitest 前提で進めます。
| 機能 | Vitest | Jest |
|---|---|---|
| モック | vi.fn() / vi.mock() | jest.fn() / jest.mock() |
| タイマー | vi.useFakeTimers() | jest.useFakeTimers() |
| 設定ファイル | vitest.config.ts | jest.config.ts |
| 速度 | 非常に高速(esbuild) | 普通(swc/babel) |
| Vite統合 | 同じ設定を共有 | 別途設定 |
最初のテスト:render と screen を使いこなす
RTL のコアAPIは render と screen です。render はテスト対象をDOMにマウントし、screen はそのDOMに対するクエリの入口を提供します。render の戻り値からクエリを取り出す書き方もできますが、2026年現在は screen からのアクセスを推奨します(複数renderしても破綻しない)。
対象コンポーネント
// src/components/Greeting.tsx
type Props = { name: string };
export const Greeting = ({ name }: Props) => (
<section aria-label="greeting">
<h1>Hello, {name}</h1>
<p>Welcome to RTL.</p>
</section>
);
最小テスト
// src/components/Greeting.test.tsx
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { Greeting } from "./Greeting";
describe("Greeting", () => {
it("名前を含む見出しを表示する", () => {
render(<Greeting name="Turkey" />);
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
"Hello, Turkey"
);
expect(screen.getByText(/welcome to rtl/i)).toBeInTheDocument();
});
});
ポイントは「getByRole を最優先で使う」ことです。視覚障害の有無に関わらず、スクリーンリーダー利用者が辿る順序とほぼ同じになり、結果として a11y が自然に高まります。
6種類のクエリと使い分け:getBy / queryBy / findBy × 単数 / All
クエリの違いは「見つからなかった時の挙動」と「非同期かどうか」の2軸です。混同しがちなので、決定木として整理します。
| 接頭辞 | 見つからない時 | 複数見つかった時 | 非同期 | 用途 |
|---|---|---|---|---|
| getBy | throw | throw | 同期 | 1個必ず存在する要素 |
| queryBy | null | throw | 同期 | 存在しないことの確認 |
| findBy | Promise reject | throw | 非同期 | 後から現れる要素 |
| getAllBy | throw | 配列 | 同期 | 必ず複数ある要素 |
| queryAllBy | [] | 配列 | 同期 | 0〜N個の配列 |
| findAllBy | Promise reject | Promise解決 | 非同期 | 後から複数現れる |
「存在しないこと」を検査するアンチパターンと正解
// NG: getByは見つからないと例外なので、negative assertionに使えない
expect(screen.getByText("エラー")).not.toBeInTheDocument(); // ← 例外で死ぬ
// OK: queryByはnullを返すのでnotマッチに使える
expect(screen.queryByText("エラー")).not.toBeInTheDocument();
非同期で現れる要素は findBy
it("ボタン押下後にメッセージが表示される", async () => {
render(<AsyncMessage />);
await userEvent.click(screen.getByRole("button", { name: "送信" }));
// setTimeout後やfetch後など、後から現れる要素はfindBy
expect(await screen.findByText("送信完了")).toBeInTheDocument();
});
クエリの優先順位:byRole を最優先にする理由
RTL 公式は「Accessible by Everyone → Semantic Queries → Test ID」の3段階優先順位を推奨しています。これに従うだけで、テストが壊れにくくなり、a11y も向上します。
- 第1優先(誰でもアクセス可能):
getByRole/getByLabelText/getByPlaceholderText/getByText/getByDisplayValue - 第2優先(セマンティック):
getByAltText/getByTitle - 最終手段:
getByTestId(role が当てられない・テキストもない場合のみ)
同じボタンを byRole で書き分ける
// <button>送信</button> を取得
screen.getByRole("button", { name: "送信" });
// <a href="/about">会社情報</a> を取得
screen.getByRole("link", { name: "会社情報" });
// <input type="checkbox" aria-label="利用規約に同意" /> を取得
screen.getByRole("checkbox", { name: "利用規約に同意" });
// 階層が深いheading
screen.getByRole("heading", { level: 2, name: /設定/ });
フォーム要素は byLabelText が最も堅牢
// <label htmlFor="email">メールアドレス</label>
// <input id="email" type="email" />
const input = screen.getByLabelText("メールアドレス");
expect(input).toHaveAttribute("type", "email");
byTestId は最終手段
// JSX側 <div data-testid="result-panel"> ... </div>
// 「役割がない純粋なコンテナ」かつ「テキストが可変」な時だけ使う
const panel = screen.getByTestId("result-panel");
expect(panel).toHaveClass("is-active");
userEvent vs fireEvent:必ず userEvent を選ぶ
fireEvent は生のDOMイベントを直接dispatchするだけで、userEvent は「フォーカス→keydown→keypress→input→change→keyup」のように実ユーザーの操作シーケンス全てを再現します。例えばテキスト入力テストで、IME や onFocus 起因のバリデーションを正しく検知できるのは userEvent だけです。
userEvent v14 のセットアップ
import userEvent from "@testing-library/user-event";
it("クリック動作", async () => {
// v14以降はsetup()してuserインスタンス経由で操作
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole("button", { name: "+1" }));
expect(screen.getByText("count: 1")).toBeInTheDocument();
});
キーボード操作・複合操作
const user = userEvent.setup();
await user.type(screen.getByLabelText("検索"), "react testing");
await user.keyboard("{Enter}");
await user.tab(); // 次のフォーカス可能要素へ
await user.clear(screen.getByLabelText("検索"));
await user.selectOptions(screen.getByLabelText("カテゴリ"), "react");
await user.upload(
screen.getByLabelText("画像"),
new File(["dummy"], "a.png", { type: "image/png" })
);
非同期テスト:waitFor / findBy / act の正しい使い分け
非同期テストは「DOMに変化が現れるまで待つ」ことが本質です。RTL は3つの待機手段を提供します。
| API | 用途 | 失敗時タイムアウト | 備考 |
|---|---|---|---|
| findBy* | 「要素が現れるまで待つ」 | 1000ms(変更可) | 第一選択 |
| waitFor | 任意のassertionが通るまで待つ | 1000ms(変更可) | findByで足りない時 |
| act | state更新をflush | — | userEvent内部で自動 |
findBy の内部は waitFor + getBy
// findBy は内部的にこう書くのと同じ
await waitFor(() => {
expect(screen.getByText("ロード完了")).toBeInTheDocument();
});
// なので普通は findBy で書けば十分
expect(await screen.findByText("ロード完了")).toBeInTheDocument();
「消える」ことの確認は waitForElementToBeRemoved
import { waitForElementToBeRemoved } from "@testing-library/react";
it("ローディング表示が消える", async () => {
render(<UserList />);
await waitForElementToBeRemoved(() => screen.queryByText("読み込み中..."));
expect(screen.getByRole("list")).toBeInTheDocument();
});
fake timers と非同期の組み合わせ
it("3秒後にメッセージが消える", async () => {
vi.useFakeTimers();
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<Toast />);
await user.click(screen.getByRole("button", { name: "通知を出す" }));
expect(screen.getByRole("status")).toHaveTextContent("保存しました");
await act(async () => {
vi.advanceTimersByTime(3000);
});
expect(screen.queryByRole("status")).not.toBeInTheDocument();
vi.useRealTimers();
});
カスタムレンダラ:Provider群を1か所で巻く
実プロジェクトでは Router・QueryClient・Theme・i18n・Auth など複数の Provider が必要です。テストごとに巻くと地獄になるので、カスタムレンダラを1つ作って全テストでそれを使うのがベストプラクティスです。
renderWithProviders の定義
// src/test/renderWithProviders.tsx
import { ReactElement, ReactNode } from "react";
import { render, RenderOptions } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";
import { ThemeProvider } from "../theme/ThemeProvider";
type Options = {
route?: string;
queryClient?: QueryClient;
} & Omit<RenderOptions, "wrapper">;
export function renderWithProviders(
ui: ReactElement,
{
route = "/",
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
}),
...rest
}: Options = {}
) {
const Wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>
</ThemeProvider>
</QueryClientProvider>
);
return { queryClient, ...render(ui, { wrapper: Wrapper, ...rest }) };
}
使う側はこれだけ
import { renderWithProviders } from "@/test/renderWithProviders";
it("/users で一覧が見える", async () => {
renderWithProviders(<App />, { route: "/users" });
expect(await screen.findByRole("heading", { name: "ユーザー一覧" })).toBeInTheDocument();
});
テスト内で retry: false にしておくのは重要です。react-query はデフォルトで失敗時に複数回リトライするため、エラーパステストがタイムアウトする原因になります。
カスタムフックを renderHook で単体テストする
RTL は renderHook を提供しており、カスタムフックを「ダミーコンポーネントを書かずに」テストできます。
対象フック
// src/hooks/useToggle.ts
import { useCallback, useState } from "react";
export function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn(v => !v), []);
const set = useCallback((v: boolean) => setOn(v), []);
return { on, toggle, set } as const;
}
renderHook + act
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { useToggle } from "./useToggle";
describe("useToggle", () => {
it("初期値false / toggleで反転", () => {
const { result } = renderHook(() => useToggle());
expect(result.current.on).toBe(false);
act(() => result.current.toggle());
expect(result.current.on).toBe(true);
});
it("setで明示指定", () => {
const { result } = renderHook(() => useToggle(true));
act(() => result.current.set(false));
expect(result.current.on).toBe(false);
});
it("rerenderしても関数のidentityが変わらない", () => {
const { result, rerender } = renderHook(() => useToggle());
const first = result.current.toggle;
rerender();
expect(result.current.toggle).toBe(first);
});
});
Provider が必要なフックは wrapper を渡す
import { renderHook } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useUser } from "./useUser";
it("useUser はキャッシュされたユーザーを返す", async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useUser(1), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.name).toBe("Alice");
});
MSW でAPI層を一括モックする(2.x系)
fetch を vi.fn().mockResolvedValue(...) でモックする方式は、URL や HTTP メソッドの違いに弱く、実装変更で壊れやすいです。2026年現在は MSW(Mock Service Worker)2.x で「ネットワーク層」を丸ごとモックする方式がデファクトです。
インストールと handlers
npm i -D msw
// src/test/mocks/handlers.ts
import { http, HttpResponse } from "msw";
type User = { id: number; name: string };
const users: User[] = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
];
export const handlers = [
http.get("/api/users", () => HttpResponse.json(users)),
http.get("/api/users/:id", ({ params }) => {
const u = users.find(u => u.id === Number(params.id));
return u
? HttpResponse.json(u)
: new HttpResponse(null, { status: 404 });
}),
http.post<never, { name: string }>("/api/users", async ({ request }) => {
const body = await request.json();
const created = { id: users.length + 1, name: body.name };
users.push(created);
return HttpResponse.json(created, { status: 201 });
}),
];
Node 用 server セットアップ
// src/test/mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
Vitest setup に組み込む
// src/test/setup.ts
import "@testing-library/jest-dom/vitest";
import { afterAll, afterEach, beforeAll } from "vitest";
import { cleanup } from "@testing-library/react";
import { server } from "./mocks/server";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
onUnhandledRequest: "error" にしておくと、テストが想定外のAPIを叩いた瞬間に失敗するため、モック漏れを早期発見できます。
APIモック付きコンポーネントテストの実例
対象コンポーネント
// src/features/users/UserList.tsx
import { useQuery } from "@tanstack/react-query";
type User = { id: number; name: string };
async function fetchUsers(): Promise<User[]> {
const res = await fetch("/api/users");
if (!res.ok) throw new Error("failed");
return res.json();
}
export const UserList = () => {
const { data, isLoading, isError } = useQuery({
queryKey: ["users"],
queryFn: fetchUsers,
});
if (isLoading) return <p role="status">読み込み中...</p>;
if (isError) return <p role="alert">取得に失敗しました</p>;
return (
<ul aria-label="users">
{data!.map(u => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
};
正常系・ローディング・エラーの3パターン
// src/features/users/UserList.test.tsx
import { screen, waitForElementToBeRemoved } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { server } from "@/test/mocks/server";
import { renderWithProviders } from "@/test/renderWithProviders";
import { UserList } from "./UserList";
describe("UserList", () => {
it("ローディング後にユーザーが表示される", async () => {
renderWithProviders(<UserList />);
expect(screen.getByRole("status")).toHaveTextContent("読み込み中");
await waitForElementToBeRemoved(() => screen.queryByRole("status"));
const items = screen.getAllByRole("listitem");
expect(items).toHaveLength(2);
expect(screen.getByText("Alice")).toBeInTheDocument();
});
it("APIが500ならエラー表示", async () => {
server.use(
http.get("/api/users", () => new HttpResponse(null, { status: 500 }))
);
renderWithProviders(<UserList />);
expect(await screen.findByRole("alert")).toHaveTextContent("失敗");
});
it("空配列なら listitem は0個", async () => {
server.use(http.get("/api/users", () => HttpResponse.json([])));
renderWithProviders(<UserList />);
await waitForElementToBeRemoved(() => screen.queryByRole("status"));
expect(screen.queryAllByRole("listitem")).toHaveLength(0);
});
});
server.use(...) はそのテストケース限定で handler を上書きします。afterEach の server.resetHandlers() で必ず元に戻ります。
フォームテスト:バリデーション・submit・サーバーエラー
対象フォーム(React Hook Form想定)
// src/features/auth/LoginForm.tsx
import { useForm } from "react-hook-form";
type Inputs = { email: string; password: string };
export const LoginForm = ({ onSuccess }: { onSuccess: () => void }) => {
const { register, handleSubmit, formState: { errors }, setError } =
useForm<Inputs>();
const onSubmit = async (data: Inputs) => {
const res = await fetch("/api/login", {
method: "POST",
body: JSON.stringify(data),
});
if (!res.ok) {
setError("root", { message: "メールかパスワードが違います" });
return;
}
onSuccess();
};
return (
<form onSubmit={handleSubmit(onSubmit)} aria-label="login">
<label>
メールアドレス
<input type="email" {...register("email", { required: true })} />
</label>
{errors.email && <span role="alert">メール必須</span>}
<label>
パスワード
<input type="password" {...register("password", { required: true, minLength: 8 })} />
</label>
{errors.password && <span role="alert">8文字以上</span>}
{errors.root && <p role="alert">{errors.root.message}</p>}
<button type="submit">ログイン</button>
</form>
);
};
フォームテスト全パターン
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { server } from "@/test/mocks/server";
import { renderWithProviders } from "@/test/renderWithProviders";
import { LoginForm } from "./LoginForm";
describe("LoginForm", () => {
it("空送信でバリデーションエラー", async () => {
const user = userEvent.setup();
renderWithProviders(<LoginForm onSuccess={() => {}} />);
await user.click(screen.getByRole("button", { name: "ログイン" }));
const errors = await screen.findAllByRole("alert");
expect(errors).toHaveLength(2);
});
it("成功時 onSuccess が呼ばれる", async () => {
const user = userEvent.setup();
const onSuccess = vi.fn();
server.use(http.post("/api/login", () => HttpResponse.json({ ok: true })));
renderWithProviders(<LoginForm onSuccess={onSuccess} />);
await user.type(screen.getByLabelText("メールアドレス"), "a@b.co");
await user.type(screen.getByLabelText("パスワード"), "password123");
await user.click(screen.getByRole("button", { name: "ログイン" }));
await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1));
});
it("401時にrootエラー表示", async () => {
const user = userEvent.setup();
server.use(http.post("/api/login", () => new HttpResponse(null, { status: 401 })));
renderWithProviders(<LoginForm onSuccess={() => {}} />);
await user.type(screen.getByLabelText("メールアドレス"), "a@b.co");
await user.type(screen.getByLabelText("パスワード"), "password123");
await user.click(screen.getByRole("button", { name: "ログイン" }));
expect(await screen.findByText(/メールかパスワード/)).toBeInTheDocument();
});
});
ルーティングテスト:MemoryRouter で完結させる
// src/App.tsx
import { Routes, Route, Link } from "react-router-dom";
export const App = () => (
<>
<nav>
<Link to="/">トップ</Link>
<Link to="/about">会社情報</Link>
</nav>
<Routes>
<Route path="/" element={<h1>Home</h1>} />
<Route path="/about" element={<h1>About</h1>} />
<Route path="*" element={<h1>Not Found</h1>} />
</Routes>
</>
);
import { MemoryRouter } from "react-router-dom";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { App } from "./App";
it("初期URLに応じた画面が出る", () => {
render(
<MemoryRouter initialEntries={["/about"]}>
<App />
</MemoryRouter>
);
expect(screen.getByRole("heading", { name: "About" })).toBeInTheDocument();
});
it("リンククリックで画面遷移", async () => {
const user = userEvent.setup();
render(
<MemoryRouter>
<App />
</MemoryRouter>
);
await user.click(screen.getByRole("link", { name: "会社情報" }));
expect(screen.getByRole("heading", { name: "About" })).toBeInTheDocument();
});
it("未定義ルートは404", () => {
render(
<MemoryRouter initialEntries={["/no-such"]}>
<App />
</MemoryRouter>
);
expect(screen.getByRole("heading", { name: "Not Found" })).toBeInTheDocument();
});
Context Provider のテスト
// src/auth/AuthContext.tsx
import { createContext, useContext, useState, ReactNode } from "react";
type Auth = { user: string | null; login: (n: string) => void; logout: () => void };
const Ctx = createContext<Auth | null>(null);
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<string | null>(null);
return (
<Ctx.Provider value={{ user, login: setUser, logout: () => setUser(null) }}>
{children}
</Ctx.Provider>
);
};
export const useAuth = () => {
const v = useContext(Ctx);
if (!v) throw new Error("useAuth must be inside AuthProvider");
return v;
};
import { renderHook, act } from "@testing-library/react";
import { AuthProvider, useAuth } from "./AuthContext";
it("login で user が更新される", () => {
const { result } = renderHook(() => useAuth(), { wrapper: AuthProvider });
expect(result.current.user).toBeNull();
act(() => result.current.login("turkey"));
expect(result.current.user).toBe("turkey");
});
it("Provider 外で使うと throw", () => {
// console.error を抑制したい場合は spyOn しておく
expect(() => renderHook(() => useAuth())).toThrow(/AuthProvider/);
});
エラーバウンダリのテスト
// src/components/ErrorBoundary.tsx
import { Component, ReactNode } from "react";
type State = { hasError: boolean };
export class ErrorBoundary extends Component<{ children: ReactNode }, State> {
state: State = { hasError: false };
static getDerivedStateFromError() { return { hasError: true }; }
render() {
if (this.state.hasError) return <p role="alert">エラーが発生しました</p>;
return this.props.children;
}
}
const Boom = () => { throw new Error("boom"); };
it("子が例外を投げたらフォールバックが出る", () => {
// React は意図的なthrowもconsole.errorに出すので抑止すると見やすい
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
render(
<ErrorBoundary>
<Boom />
</ErrorBoundary>
);
expect(screen.getByRole("alert")).toHaveTextContent("エラーが発生");
spy.mockRestore();
});
モック戦略:何をモックし、何をしないか
- モックすべき:ネットワーク(MSW)、時刻(
vi.useFakeTimers)、ランダム値(vi.spyOn(Math, "random"))、外部SDK(Stripe等) - モックしないべき:自分が書いた純粋関数、子コンポーネント、Context、Routerのナビゲーション
- 原則:「実装の置き換え」ではなく「外界との境界線だけ偽装」する
関数モックの基本
// vi.fn のチェック
const onClick = vi.fn();
await user.click(screen.getByRole("button"));
expect(onClick).toHaveBeenCalledTimes(1);
expect(onClick).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }));
モジュールモック
// 外部ライブラリの一部だけを差し替えたい場合
vi.mock("nanoid", () => ({
nanoid: () => "fixed-id-001",
}));
snapshot テストとの付き合い方
RTL コミュニティ的に snapshot は限定使用が推奨されます。「DOM全体のsnapshot」はprops変更で簡単に壊れ、レビュー時に意味を持たないからです。使うなら「短い・小さい・意味のある単位」に絞ります。
it("プライスタグの整形が崩れない", () => {
render(<PriceTag value={1234567} />);
expect(screen.getByLabelText("price").textContent)
.toMatchInlineSnapshot(`"¥1,234,567"`);
});
カバレッジ・CI 連携・Storybook 統合
カバレッジコマンド
# package.json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
GitHub Actions ワークフロー
# .github/workflows/test.yml
name: test
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run test:coverage
- uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
Storybook の Story を RTL でテストする
// Button.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { expect, fn, userEvent, within } from "@storybook/test";
import { Button } from "./Button";
const meta: Meta<typeof Button> = { component: Button, args: { onClick: fn() } };
export default meta;
export const ClickEmitsEvent: StoryObj<typeof Button> = {
args: { children: "送信" },
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "送信" }));
await expect(args.onClick).toHaveBeenCalledOnce();
},
};
Storybook Test の play 関数は RTL と同じ userEvent/within APIを使います。RTLで書いた知識がそのまま使えるのが利点です。
E2E と Visual Regression:Playwright との分業
RTL は jsdom 上の単体・統合テスト用途で、実ブラウザ挙動は Playwright に任せます。多くのチームでは「ハッピーパス1〜3本だけ Playwright、残りは RTL」という分業が現実的です。
// e2e/login.spec.ts
import { test, expect } from "@playwright/test";
test("ログインしてダッシュボードに遷移する", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("メールアドレス").fill("a@b.co");
await page.getByLabel("パスワード").fill("password123");
await page.getByRole("button", { name: "ログイン" }).click();
await expect(page.getByRole("heading", { name: "ダッシュボード" })).toBeVisible();
});
// Visual Regression
test("トップページのスクリーンショット", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveScreenshot("home.png", { maxDiffPixelRatio: 0.01 });
});
Playwright のクエリAPI(getByRole / getByLabel)は RTL から強い影響を受けて設計されています。学習コストが二重にならないのが大きな利点です。
パフォーマンスと debug のコツ
テストが遅い時の最初の手
- retry を切る:
new QueryClient({ defaultOptions: { queries: { retry: false } } }) - 不要な fake timer を入れていないか確認
- jsdom の
computedStyle大量呼び出しを避ける - テスト並列度を上げる:
vitest --pool=threads
DOMが見えなくてassertionが書けない時
import { screen, prettyDOM, logRoles } from "@testing-library/react";
// 今のDOMを綺麗に出す
screen.debug();
// 部分だけ
console.log(prettyDOM(screen.getByRole("form")));
// 利用可能なrole一覧を吐く
logRoles(document.body);
Testing Playground
import { screen } from "@testing-library/react";
// テスト失敗時にこの行を入れて、コンソールに出るURLをブラウザで開くと
// 「このDOMに対する最良のクエリ」を提案してくれる
screen.logTestingPlaygroundURL();
よくある質問(FAQ)
Q1. Enzyme から RTL に移行する価値はありますか?
あります。Enzyme は React 18/19 に追随できておらず、メンテナンスが停滞しています。さらに「内部state」へのアクセスを前提とする Enzyme のテストは、Hooks 化リファクタで全部壊れます。RTL は「ユーザー視点」だけを見るので、内部実装がHooksに変わってもテストが壊れにくいです。
Q2. Vitest と Jest はどちらを選ぶべき?
Vite プロジェクトなら Vitest 一択です。設定ファイルを共有でき、ESM・TS・JSX を素のまま流せます。CRA や Next.js の既存プロジェクトでは Jest のままで問題ありませんが、新規なら Vitest が高速かつ簡単です。
Q3. act 警告が出続けます
ほとんどの場合「非同期更新を待たずにテストが終了している」のが原因です。userEvent v14 と findBy / waitFor を併用すれば 99% 解決します。手動 act(async () => ...) は最終手段にしてください。
Q4. byTestId はどこまで許容?
「role が当たらない・テキストが動的・ラベルも無い」かつ「DOMの安定識別子が欲しい」場合のみです。逆に言えば「semantic にするとa11y も上がる」ので、まず HTML タグを見直す方を優先してください。
Q5. カバレッジ目標は何%が妥当?
新規プロジェクトは line 80% / branch 75% 程度から始め、CI で閾値を強制するのが現実的です。100% は維持コストが跳ね上がるためおすすめしません。それより「ハッピーパスとエラーパスの両方が書かれているか」を PR レビューで確認する方が効果的です。
Q6. MSW を E2E でも使えますか?
使えます。ブラウザ環境では Service Worker 経由でリクエストをintercept できます。ただし E2E は本物のステージングAPIで通すことが多いため、RTL では msw/node、Playwright では page.route() という分業がシンプルです。
Q7. Suspense や Server Components はテストできる?
クライアント側 Suspense は findBy で十分テスト可能です。React Server Components は jsdom 上での実行モデルがまだ揺れているため、現状は E2E 側(Playwright + 実アプリ)に倒すのが安全です。Client境界のコンポーネントは従来通り RTL で書けます。
まとめ:RTL を使いこなすための7原則
- クエリは
byRole→byLabelText→byText→byTestIdの順で選ぶ - イベントは
userEvent.setup()経由のみ。fireEventはほぼ使わない - 非同期は
findByを第一選択、ダメな時だけwaitFor - API は MSW でネットワーク層をモック、関数モックには戻らない
- Provider はカスタムレンダラ1つに集約
- カスタムフックは
renderHookで単体テスト - E2E と単体は分業。RTL を厚く、Playwright は薄く
テスト設計を「実装ベース」から「ユーザー体験ベース」に切り替えると、リファクタ耐性と a11y が同時に手に入ります。TanStack Query 完全実践ガイドやReact Router v7 完全実践ガイドと組み合わせて、テスト戦略までセットでチームに導入してください。

コメント