概要
@testing-library/react の render / 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を使う