react-testing-patterns

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

React Testing Patterns

React 组件与Hook测试模式

When to Use

适用场景

Activate this skill when:
  • Writing tests for React components (rendering, interaction, accessibility)
  • Testing custom hooks with
    renderHook
  • Mocking API calls with MSW (Mock Service Worker)
  • Testing async state changes (loading, error, success)
  • Auditing component accessibility with jest-axe
  • Setting up test infrastructure (providers, test utilities)
Do NOT use this skill for:
  • E2E browser tests with Playwright (use
    e2e-testing
    )
  • Backend Python tests (use
    pytest-patterns
    )
  • TDD workflow enforcement (use
    tdd-workflow
    )
  • Writing component implementation code (use
    react-frontend-expert
    )
在以下场景启用本技能:
  • 编写React组件测试(渲染、交互、无障碍)
  • 使用
    renderHook
    测试自定义Hook
  • 使用MSW(Mock Service Worker)模拟API调用
  • 测试异步状态变化(加载、错误、成功)
  • 使用jest-axe审核组件无障碍性
  • 搭建测试基础设施(提供者、测试工具)
请勿在以下场景使用本技能:
  • 使用Playwright进行端到端(E2E)浏览器测试(请使用
    e2e-testing
  • 后端Python测试(请使用
    pytest-patterns
  • TDD工作流规范(请使用
    tdd-workflow
  • 编写组件实现代码(请使用
    react-frontend-expert

Instructions

操作指南

Testing Library Philosophy

Testing Library 核心理念

Core principle: Test behavior, not implementation.
Query priority (prefer higher in the list):
  1. getByRole
    — accessible role (button, heading, textbox)
  2. getByLabelText
    — form elements with labels
  3. getByPlaceholderText
    — input placeholders
  4. getByText
    — visible text content
  5. getByDisplayValue
    — current form input value
  6. getByAltText
    — images
  7. getByTestId
    — last resort (data-testid attribute)
Interaction: Always use
userEvent
over
fireEvent
:
tsx
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);
What NOT to test:
  • Internal component state (don't test
    useState
    values directly)
  • CSS classes or styles
  • Component instance methods
  • Which hooks were called
  • Snapshot tests for dynamic content
  • Third-party library internals
核心原则: 测试行为,而非实现细节。
查询优先级(优先使用列表上方的方法):
  1. getByRole
    — 基于无障碍角色(按钮、标题、文本框)
  2. getByLabelText
    — 带标签的表单元素
  3. getByPlaceholderText
    — 带占位符的输入框
  4. getByText
    — 可见文本内容
  5. getByDisplayValue
    — 当前表单输入值
  6. getByAltText
    — 图片
  7. getByTestId
    — 最后选择(使用data-testid属性)
交互操作: 始终使用
userEvent
而非
fireEvent
tsx
import userEvent from "@testing-library/user-event";

// 推荐 — 模拟真实用户行为
const user = userEvent.setup();
await user.click(button);
await user.type(input, "hello");

// 不推荐 — 底层事件触发
fireEvent.click(button);
无需测试的内容:
  • 组件内部状态(不要直接测试
    useState
    的值)
  • CSS类或样式
  • 组件实例方法
  • 调用了哪些Hook
  • 动态内容的快照测试
  • 第三方库的内部实现

Component Test Structure

组件测试结构

Every component test follows Arrange → Act → Assert:
tsx
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: "alice@example.com" },
    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();
  });
});
每个组件测试遵循“准备(Arrange)→ 执行(Act)→ 断言(Assert)”流程:
tsx
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: "alice@example.com" },
    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();
  });
});

Async Testing

异步测试

waitFor (wait for state update)

waitFor(等待状态更新)

tsx
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();
});
tsx
it("shows user data after loading", async () => {
  render(<UserProfile userId={1} />);

  // 加载状态
  expect(screen.getByText(/loading/i)).toBeInTheDocument();

  // 等待数据加载完成
  await waitFor(() => {
    expect(screen.getByText("Alice")).toBeInTheDocument();
  });

  // 加载状态消失
  expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});

findBy (built-in waitFor)

findBy(内置waitFor的查询方法)

tsx
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();
});
Prefer
findBy*
over
waitFor
+
getBy*
for elements that appear asynchronously.
tsx
it("shows user data after loading", async () => {
  render(<UserProfile userId={1} />);

  // findBy = getBy + waitFor — 优先用于异步出现的元素
  const heading = await screen.findByRole("heading", { name: "Alice" });
  expect(heading).toBeInTheDocument();
});
对于异步出现的元素,优先使用
findBy*
而非
waitFor
+
getBy*

Testing Error States

错误状态测试

tsx
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);
});
tsx
it("shows error message on API failure", async () => {
  // 为当前测试覆盖MSW处理器
  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 Mocking

MSW API模拟

Setup a mock server for all API tests:
tsx
// 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: "alice@example.com" },
        { id: 2, displayName: "Bob", email: "bob@example.com" },
      ],
      next_cursor: null,
      has_more: false,
    });
  }),

  http.get("/api/users/:id", ({ params }) => {
    return HttpResponse.json({
      id: Number(params.id),
      displayName: "Alice",
      email: "alice@example.com",
    });
  }),

  http.post("/api/users", async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json(
      { id: 3, ...body, created_at: new Date().toISOString() },
      { status: 201 },
    );
  }),
];
tsx
// test/mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";

export const server = setupServer(...handlers);
tsx
// test/setup.ts (Vitest setup file)
import { server } from "./mocks/server";

beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Per-test handler override:
tsx
server.use(
  http.get("/api/users", () => {
    return HttpResponse.json({ items: [], next_cursor: null, has_more: false });
  }),
);
为所有API测试搭建模拟服务器:
tsx
// 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: "alice@example.com" },
        { id: 2, displayName: "Bob", email: "bob@example.com" },
      ],
      next_cursor: null,
      has_more: false,
    });
  }),

  http.get("/api/users/:id", ({ params }) => {
    return HttpResponse.json({
      id: Number(params.id),
      displayName: "Alice",
      email: "alice@example.com",
    });
  }),

  http.post("/api/users", async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json(
      { id: 3, ...body, created_at: new Date().toISOString() },
      { status: 201 },
    );
  }),
];
tsx
// test/mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";

export const server = setupServer(...handlers);
tsx
// test/setup.ts (Vitest配置文件)
import { server } from "./mocks/server";

beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
单测试处理器覆盖:
tsx
server.use(
  http.get("/api/users", () => {
    return HttpResponse.json({ items: [], next_cursor: null, has_more: false });
  }),
);

Hook Testing

Hook测试

tsx
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
  });
});
Testing hooks with TanStack Query:
tsx
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);
});
tsx
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"); // 仍为旧值

    act(() => { vi.advanceTimersByTime(300); });
    expect(result.current).toBe("world"); // 已更新为新值
  });
});
测试基于TanStack Query的Hook:
tsx
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);
});

Accessibility Testing

无障碍测试

Add to every component test file:
tsx
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();
});
在每个组件测试文件中添加以下代码:
tsx
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 Utility: Custom Render

测试工具:自定义Render函数

Create a custom render that wraps components with required providers:
tsx
// 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 });
}
创建一个自定义Render函数,将组件与所需的提供者包裹:
tsx
// 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 });
}

Examples

示例

Testing a Form Component

表单组件测试

tsx
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), "test@example.com");
    await user.type(screen.getByLabelText(/name/i), "Test User");
    await user.click(screen.getByRole("button", { name: /create/i }));

    expect(onSubmit).toHaveBeenCalledWith({
      email: "test@example.com",
      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();
  });
});
tsx
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), "test@example.com");
    await user.type(screen.getByLabelText(/name/i), "Test User");
    await user.click(screen.getByRole("button", { name: /create/i }));

    expect(onSubmit).toHaveBeenCalledWith({
      email: "test@example.com",
      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();
  });
});

Edge Cases

边缘场景

  • Components with providers: Always use a custom render function that wraps components with
    QueryClientProvider
    ,
    MemoryRouter
    , and any context providers needed.
  • Components with router: Use
    <MemoryRouter initialEntries={["/users/1"]}>
    for components that use
    useParams
    or
    useNavigate
    .
  • Flaky async tests: Prefer
    findBy*
    over
    waitFor
    +
    getBy*
    . If using
    waitFor
    , increase timeout for CI:
    waitFor(() => ..., { timeout: 5000 })
    .
  • Testing modals/portals: Use
    screen
    queries (they search the entire document), not
    container
    queries.
  • Cleanup: Testing Library auto-cleans after each test. Don't call
    cleanup()
    manually unless using a custom setup.
See
references/component-test-template.tsx
for an annotated test file template. See
references/msw-handler-examples.ts
for MSW handler patterns. See
references/hook-test-template.tsx
for hook testing patterns.
  • 带提供者的组件: 始终使用自定义Render函数,将组件与
    QueryClientProvider
    MemoryRouter
    及所需的上下文提供者包裹。
  • 带路由的组件: 对于使用
    useParams
    useNavigate
    的组件,使用
    <MemoryRouter initialEntries={["/users/1"]}>
  • 不稳定的异步测试: 优先使用
    findBy*
    而非
    waitFor
    +
    getBy*
    。如果使用
    waitFor
    ,在CI环境中增加超时时间:
    waitFor(() => ..., { timeout: 5000 })
  • 测试模态框/传送门: 使用
    screen
    查询(会搜索整个文档),而非
    container
    查询。
  • 清理工作: Testing Library会在每个测试后自动清理。除非使用自定义配置,否则无需手动调用
    cleanup()
查看
references/component-test-template.tsx
获取带注释的测试文件模板。 查看
references/msw-handler-examples.ts
获取MSW处理器模式示例。 查看
references/hook-test-template.tsx
获取Hook测试模式示例。