React Testing Library完全実践ガイド〜Vitest・MSW・カスタムフック・統合テスト【2026年版】〜

React Testing Library(以下RTL)は、Reactコンポーネントを「ユーザーが実際にどう触るか」という観点で検証するためのデファクトスタンダードです。本記事では Vitest をメインに、MSW、カスタムフック、カスタムレンダラ、フォーム、ルーティング、Context、Playwright 連携、CI まで、実プロジェクトでそのままコピペできるコード中心で総整理します。Jest 利用者向けの差分も併記します。

対象読者は、コンポーネントは書けるがテスト戦略に迷っている現役Webエンジニアです。screen.getByRoleuserEvent を中心に、実装詳細ではなく「ユーザー観察可能な振る舞い」をテストする方針を一貫して採用します。

  1. RTLの設計思想とテスト戦略の地図
  2. セットアップ:Vitest + RTL + jsdom(2026年現行版)
    1. 依存関係のインストール
    2. vitest.config.ts の最小構成
    3. setupファイル(jest-dom拡張 + cleanup)
    4. Jest を使う場合のセットアップ(参考)
  3. 最初のテスト:render と screen を使いこなす
    1. 対象コンポーネント
    2. 最小テスト
  4. 6種類のクエリと使い分け:getBy / queryBy / findBy × 単数 / All
    1. 「存在しないこと」を検査するアンチパターンと正解
    2. 非同期で現れる要素は findBy
  5. クエリの優先順位:byRole を最優先にする理由
    1. 同じボタンを byRole で書き分ける
    2. フォーム要素は byLabelText が最も堅牢
    3. byTestId は最終手段
  6. userEvent vs fireEvent:必ず userEvent を選ぶ
    1. userEvent v14 のセットアップ
    2. キーボード操作・複合操作
  7. 非同期テスト:waitFor / findBy / act の正しい使い分け
    1. findBy の内部は waitFor + getBy
    2. 「消える」ことの確認は waitForElementToBeRemoved
    3. fake timers と非同期の組み合わせ
  8. カスタムレンダラ:Provider群を1か所で巻く
    1. renderWithProviders の定義
    2. 使う側はこれだけ
  9. カスタムフックを renderHook で単体テストする
    1. 対象フック
    2. renderHook + act
    3. Provider が必要なフックは wrapper を渡す
  10. MSW でAPI層を一括モックする(2.x系)
    1. インストールと handlers
    2. Node 用 server セットアップ
    3. Vitest setup に組み込む
  11. APIモック付きコンポーネントテストの実例
    1. 対象コンポーネント
    2. 正常系・ローディング・エラーの3パターン
  12. フォームテスト:バリデーション・submit・サーバーエラー
    1. 対象フォーム(React Hook Form想定)
    2. フォームテスト全パターン
  13. ルーティングテスト:MemoryRouter で完結させる
  14. Context Provider のテスト
  15. エラーバウンダリのテスト
  16. モック戦略:何をモックし、何をしないか
    1. 関数モックの基本
    2. モジュールモック
  17. snapshot テストとの付き合い方
  18. カバレッジ・CI 連携・Storybook 統合
    1. カバレッジコマンド
    2. GitHub Actions ワークフロー
    3. Storybook の Story を RTL でテストする
  19. E2E と Visual Regression:Playwright との分業
  20. パフォーマンスと debug のコツ
    1. テストが遅い時の最初の手
    2. DOMが見えなくてassertionが書けない時
    3. Testing Playground
  21. よくある質問(FAQ)
    1. Q1. Enzyme から RTL に移行する価値はありますか?
    2. Q2. Vitest と Jest はどちらを選ぶべき?
    3. Q3. act 警告が出続けます
    4. Q4. byTestId はどこまで許容?
    5. Q5. カバレッジ目標は何%が妥当?
    6. Q6. MSW を E2E でも使えますか?
    7. Q7. Suspense や Server Components はテストできる?
  22. まとめ: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+RouterRTL + MSW + MemoryRouter中心
E2Eテスト実ブラウザ全体動作Playwright連携のみ
Visual RegressionUIの見た目差分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 前提で進めます。

機能VitestJest
モックvi.fn() / vi.mock()jest.fn() / jest.mock()
タイマーvi.useFakeTimers()jest.useFakeTimers()
設定ファイルvitest.config.tsjest.config.ts
速度非常に高速(esbuild)普通(swc/babel)
Vite統合同じ設定を共有別途設定
<!– /wp:table

最初のテスト:render と screen を使いこなす

RTL のコアAPIは renderscreen です。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軸です。混同しがちなので、決定木として整理します。

接頭辞見つからない時複数見つかった時非同期用途
getBythrowthrow同期1個必ず存在する要素
queryBynullthrow同期存在しないことの確認
findByPromise rejectthrow非同期後から現れる要素
getAllBythrow配列同期必ず複数ある要素
queryAllBy[]配列同期0〜N個の配列
findAllByPromise rejectPromise解決非同期後から複数現れる

「存在しないこと」を検査するアンチパターンと正解

// 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で足りない時
actstate更新をflushuserEvent内部で自動

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 を上書きします。afterEachserver.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原則

  • クエリは byRolebyLabelTextbyTextbyTestId の順で選ぶ
  • イベントは userEvent.setup() 経由のみ。fireEvent はほぼ使わない
  • 非同期は findBy を第一選択、ダメな時だけ waitFor
  • API は MSW でネットワーク層をモック、関数モックには戻らない
  • Provider はカスタムレンダラ1つに集約
  • カスタムフックは renderHook で単体テスト
  • E2E と単体は分業。RTL を厚く、Playwright は薄く

テスト設計を「実装ベース」から「ユーザー体験ベース」に切り替えると、リファクタ耐性と a11y が同時に手に入ります。TanStack Query 完全実践ガイドReact Router v7 完全実践ガイドと組み合わせて、テスト戦略までセットでチームに導入してください。

コメント

タイトルとURLをコピーしました