レビュー待ち·難易度: 中級·更新: 2026-04-18

React Testing Library でコンポーネントをテストする

render / screen / userEvent を使い、コンポーネントの表示・インタラクション・非同期更新を単体テストする例。jest-custom-hook-test のフック版に対するコンポーネント版。

nextjstestingjest

対応バージョン

nextjs 15react 19jest 29

前提環境

Jest の基本と React コンポーネントの書き方を理解していること

概要

@testing-library/reactrender / screen / userEvent を使い、コンポーネントの表示・ユーザー操作・非同期更新を単体テストする。jest-custom-hook-test(フックテスト)の対となる、コンポーネントレベルのテストパターン。

インストール

npm install jest @types/jest ts-jest
npm install --save-dev @testing-library/react @testing-library/user-event @testing-library/jest-dom jest-environment-jsdom

実装

テスト対象コンポーネント

// components/Counter.tsx
"use client";

import { useState } from "react";

type Props = { initialCount?: number };

export function Counter({ initialCount = 0 }: Props) {
  const [count, setCount] = useState(initialCount);

  return (
    <div>
      <p data-testid="count">カウント: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>増やす</button>
      <button onClick={() => setCount((c) => c - 1)}>減らす</button>
      <button onClick={() => setCount(initialCount)}>リセット</button>
    </div>
  );
}
// components/LoginForm.tsx
"use client";

import { useState } from "react";

type Props = { onSubmit: (email: string, password: string) => Promise<void> };

export function LoginForm({ onSubmit }: Props) {
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const fd = new FormData(e.currentTarget);
    setLoading(true);
    setError(null);
    try {
      await onSubmit(fd.get("email") as string, fd.get("password") as string);
    } catch (err) {
      setError(err instanceof Error ? err.message : "エラーが発生しました");
    } finally {
      setLoading(false);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" type="email" placeholder="メール" />
      <input name="password" type="password" placeholder="パスワード" />
      <button type="submit" disabled={loading}>
        {loading ? "送信中..." : "ログイン"}
      </button>
      {error && <p role="alert">{error}</p>}
    </form>
  );
}

Counter コンポーネントのテスト

// components/Counter.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Counter } from "./Counter";

describe("Counter", () => {
  it("初期値を表示する", () => {
    render(<Counter initialCount={5} />);
    expect(screen.getByTestId("count")).toHaveTextContent("カウント: 5");
  });

  it("「増やす」ボタンでカウントが増える", async () => {
    const user = userEvent.setup();
    render(<Counter />);

    await user.click(screen.getByRole("button", { name: "増やす" }));

    expect(screen.getByTestId("count")).toHaveTextContent("カウント: 1");
  });

  it("「リセット」ボタンで初期値に戻る", async () => {
    const user = userEvent.setup();
    render(<Counter initialCount={3} />);

    await user.click(screen.getByRole("button", { name: "増やす" }));
    await user.click(screen.getByRole("button", { name: "リセット" }));

    expect(screen.getByTestId("count")).toHaveTextContent("カウント: 3");
  });
});

LoginForm の非同期テスト

// components/LoginForm.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoginForm } from "./LoginForm";

describe("LoginForm", () => {
  it("送信中はボタンが無効になる", async () => {
    const user = userEvent.setup();
    // 解決しない Promise でローディング状態を維持
    const onSubmit = jest.fn(() => new Promise<void>(() => {}));
    render(<LoginForm onSubmit={onSubmit} />);

    await user.type(screen.getByPlaceholderText("メール"), "test@example.com");
    await user.type(screen.getByPlaceholderText("パスワード"), "password");
    await user.click(screen.getByRole("button", { name: "ログイン" }));

    expect(screen.getByRole("button", { name: "送信中..." })).toBeDisabled();
  });

  it("エラー時にメッセージを表示する", async () => {
    const user = userEvent.setup();
    const onSubmit = jest.fn().mockRejectedValueOnce(new Error("認証失敗"));
    render(<LoginForm onSubmit={onSubmit} />);

    await user.type(screen.getByPlaceholderText("メール"), "bad@example.com");
    await user.type(screen.getByPlaceholderText("パスワード"), "wrong");
    await user.click(screen.getByRole("button", { name: "ログイン" }));

    await waitFor(() => {
      expect(screen.getByRole("alert")).toHaveTextContent("認証失敗");
    });
  });

  it("成功時は onSubmit が正しい引数で呼ばれる", async () => {
    const user = userEvent.setup();
    const onSubmit = jest.fn().mockResolvedValueOnce(undefined);
    render(<LoginForm onSubmit={onSubmit} />);

    await user.type(screen.getByPlaceholderText("メール"), "user@example.com");
    await user.type(screen.getByPlaceholderText("パスワード"), "secret");
    await user.click(screen.getByRole("button", { name: "ログイン" }));

    await waitFor(() => {
      expect(onSubmit).toHaveBeenCalledWith("user@example.com", "secret");
    });
  });
});

ポイント

  • screen.getByRole("button", { name: "テキスト" }) でアクセシブルなクエリを使う。getByTestId より役割ベースのクエリを優先するとリファクタに強い
  • userEvent.setup() + await user.click()fireEvent.click() より実際のブラウザ操作に近い(キーボードイベント等も発火)
  • 非同期の状態変化は waitFor でポーリングして待つ。findBy* クエリを使っても同じ効果がある
  • jest.fn().mockResolvedValueOnce() / mockRejectedValueOnce() でコールバックの成功・失敗を制御する
  • jest-custom-hook-test との使い分け: ロジックが複雑なフックは renderHook、UI 込みで検証したい場合は render + screen を使う

注意点

jest-custom-hook-test は renderHook でフックのみテスト。こちらは render + screen + userEvent でコンポーネント全体をテストするパターン。

関連サンプル