react-testing-patterns について
Reactのコンポーネント、フック、非同期状態をテストするためのスキルです。ユーザーから見える動作を重視し、MSWによるAPIモック、アクセシビリティ検査、テスト環境の設定を扱います。
主な機能
- 01ユーザーの動作に基づくコンポーネントテスト
- 02userEventによる操作のシミュレーション
- 03MSWによるAPIリクエストのモック
- 04フックと非同期状態のテスト
- 05jest-axeによるアクセシビリティ検査
- 06プロバイダー付きカスタムレンダーの設定
活用シーン
- 01フォーム送信やコンポーネント操作のテスト
- 02APIの読み込み・成功・エラー状態の検証
- 03カスタムフックの動作確認
- 04コンポーネントのアクセシビリティ検査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ hieutrtr/ ai1-skills --skill react-testing-patternsインストール後、Claude Code で/react-testing-patternsを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/component-test-template.tsxhook-test-template.tsxmsw-handler-examples.ts詳細
React テストパターン
使用する場面
次の場合にこのスキルを有効にしてください。
- React コンポーネントのテスト(レンダリング、操作、アクセシビリティ)を書く場合
renderHookを使ってカスタムフックをテストする場合- MSW(Mock Service Worker)で API 呼び出しをモックする場合
- 非同期の状態変化(読み込み、エラー、成功)をテストする場合
- jest-axe でコンポーネントのアクセシビリティを監査する場合
- テスト基盤(プロバイダー、テストユーティリティ)を設定する場合
次の場合はこのスキルを使用しないでください。
- Playwright を使った E2E ブラウザーテスト(
e2e-testingを使用) - バックエンドの Python テスト(
pytest-patternsを使用) - TDD ワークフローの適用(
tdd-workflowを使用) - コンポーネントの実装コードを書く場合(
react-frontend-expertを使用)
手順
Testing Library の考え方
基本原則: 実装ではなく、振る舞いをテストします。
クエリの優先順位(上位のものを優先してください)。
getByRole— アクセシブルなロール(button、heading、textbox)getByLabelText— ラベルのあるフォーム要素getByPlaceholderText— 入力欄のプレースホルダーgetByText— 表示されるテキスト内容getByDisplayValue— 現在のフォーム入力値getByAltText— 画像getByTestId— 最終手段(data-testid 属性)
操作: fireEvent ではなく、必ず userEvent を使用してください。
import userEvent from "@testing-library/user-event";
// Good — simulates real user behavior
const user = userEvent.setup();
await user.click(button);
await user.type(input, "hello");
// Bad — low-level event dispatch
fireEvent.click(button);
テストしないもの:
- コンポーネントの内部状態(
useStateの値を直接テストしないでください) - CSS クラスやスタイル
- コンポーネントのインスタンスメソッド
- 呼び出されたフックの種類
- 動的なコンテンツのスナップショットテスト
- サードパーティライブラリの内部実装
コンポーネントテストの構成
すべてのコンポーネントテストは、Arrange → Act → Assert の順で構成します。
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "jest-axe";
import { UserCard } from "./UserCard";
describe("UserCard", () => {
const defaultProps = {
user: { id: 1, displayName: "Alice", email: "[email protected]" },
onEdit: vi.fn(),
};
it("renders user name", () => {
// Arrange
render(<UserCard {...defaultProps} />);
// Assert
expect(screen.getByText("Alice")).toBeInTheDocument();
});
it("calls onEdit when edit button is clicked", async () => {
// Arrange
const user = userEvent.setup();
render(<UserCard {...defaultProps} />);
// Act
await user.click(screen.getByRole("button", { name: /edit/i }));
// Assert
expect(defaultProps.onEdit).toHaveBeenCalledWith(1);
});
it("has no accessibility violations", async () => {
const { container } = render(<UserCard {...defaultProps} />);
expect(await axe(container)).toHaveNoViolations();
});
});
非同期テスト
waitFor(状態更新を待機)
it("shows user data after loading", async () => {
render(<UserProfile userId={1} />);
// Loading state
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Wait for data to appear
await waitFor(() => {
expect(screen.getByText("Alice")).toBeInTheDocument();
});
// Loading state gone
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
findBy(組み込みの waitFor)
it("shows user data after loading", async () => {
render(<UserProfile userId={1} />);
// findBy = getBy + waitFor — preferred for async appearance
const heading = await screen.findByRole("heading", { name: "Alice" });
expect(heading).toBeInTheDocument();
});
非同期で表示される要素には、waitFor と getBy* の組み合わせよりも findBy* を優先してください。
エラー状態のテスト
it("shows error message on API failure", async () => {
// Override MSW handler for this test
server.use(
http.get("/api/users/:id", () => {
return HttpResponse.json(
{ detail: "User not found" },
{ status: 404 },
);
}),
);
render(<UserProfile userId={999} />);
const error = await screen.findByRole("alert");
expect(error).toHaveTextContent(/not found/i);
});
MSW による API のモック
すべての API テスト用にモックサーバーを設定します。
// test/mocks/handlers.ts
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/users", () => {
return HttpResponse.json({
items: [
{ id: 1, displayName: "Alice", email: "[email protected]" },
{ id: 2, displayName: "Bob", email: "[email protected]" },
],
next_cursor: null,
has_more: false,
});
}),
http.get("/api/users/:id", ({ params }) => {
return HttpResponse.json({
id: Number(params.id),
displayName: "Alice",
email: "[email protected]",
});
}),
http.post("/api/users", async ({ request }) => {
const body = await request.json();
return HttpResponse.json(
{ id: 3, ...body, created_at: new Date().toISOString() },
{ status: 201 },
);
}),
];
// test/mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
// test/setup.ts (Vitest setup file)
import { server } from "./mocks/server";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
テストごとのハンドラーのオーバーライド:
server.use(
http.get("/api/users", () => {
return HttpResponse.json({ items: [], next_cursor: null, has_more: false });
}),
);
フックのテスト
import { renderHook, act } from "@testing-library/react";
import { useDebounce } from "./useDebounce";
describe("useDebounce", () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it("returns initial value immediately", () => {
const { result } = renderHook(() => useDebounce("hello", 300));
expect(result.current).toBe("hello");
});
it("debounces value changes", () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 300),
{ initialProps: { value: "hello" } },
);
rerender({ value: "world" });
expect(result.current).toBe("hello"); // Still old value
act(() => { vi.advanceTimersByTime(300); });
expect(result.current).toBe("world"); // Now updated
});
});
TanStack Query を使ったフックのテスト:
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
it("fetches users", async () => {
const { result } = renderHook(() => useUsers(), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toHaveLength(2);
});
アクセシビリティテスト
すべてのコンポーネントテストファイルに追加します。
import { axe, toHaveNoViolations } from "jest-axe";
expect.extend(toHaveNoViolations);
it("has no accessibility violations", async () => {
const { container } = render(<UserCard {...defaultProps} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
テストユーティリティ:カスタムレンダー
コンポーネントを必要なプロバイダーでラップするカスタムレンダーを作成します。
// test/utils.tsx
import { render, RenderOptions } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";
import { AuthProvider } from "@/contexts/AuthContext";
function AllProviders({ children }: { children: React.ReactNode }) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
return (
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<AuthProvider>{children}</AuthProvider>
</MemoryRouter>
</QueryClientProvider>
);
}
export function renderWithProviders(ui: React.ReactElement, options?: RenderOptions) {
return render(ui, { wrapper: AllProviders, ...options });
}
例
フォームコンポーネントのテスト
describe("CreateUserForm", () => {
it("submits valid data", async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();
render(<CreateUserForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/email/i), "[email protected]");
await user.type(screen.getByLabelText(/name/i), "Test User");
await user.click(screen.getByRole("button", { name: /create/i }));
expect(onSubmit).toHaveBeenCalledWith({
email: "[email protected]",
displayName: "Test User",
role: "member",
});
});
it("shows validation errors for empty required fields", async () => {
const user = userEvent.setup();
render(<CreateUserForm onSubmit={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /create/i }));
expect(await screen.findByText(/required/i)).toBeInTheDocument();
});
});
エッジケース
-
プロバイダーを使うコンポーネント: 必ず、コンポーネントを
QueryClientProvider、MemoryRouterおよび必要なコンテキストプロバイダーでラップするカスタムレンダー関数を使用してください。 -
ルーターを使うコンポーネント:
useParamsまたはuseNavigateを使うコンポーネントには、<MemoryRouter initialEntries={["/users/1"]}>を使用してください。 -
不安定な非同期テスト:
waitForとgetBy*の組み合わせよりも、findBy*を優先してください。waitForを使う場合は、CI 用にタイムアウトを延長してください:waitFor(() => ..., { timeout: 5000 })。 -
モーダル/ポータルのテスト:
containerクエリではなく、ドキュメント全体を検索するscreenクエリを使用してください。 -
クリーンアップ: Testing Library は各テストの後に自動でクリーンアップします。カスタムセットアップを使う場合を除き、
cleanup()を手動で呼び出さないでください。
注釈付きテストファイルのテンプレートについては、references/component-test-template.tsx を参照してください。
MSW ハンドラーパターンについては、references/msw-handler-examples.ts を参照してください。
フックのテストパターンについては、references/hook-test-template.tsx を参照してください。