react-testing-patterns
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseReact 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组件测试(渲染、交互、无障碍)
- 使用测试自定义Hook
renderHook - 使用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):
- — accessible role (button, heading, textbox)
getByRole - — form elements with labels
getByLabelText - — input placeholders
getByPlaceholderText - — visible text content
getByText - — current form input value
getByDisplayValue - — images
getByAltText - — last resort (data-testid attribute)
getByTestId
Interaction: Always use over :
userEventfireEventtsx
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 values directly)
useState - CSS classes or styles
- Component instance methods
- Which hooks were called
- Snapshot tests for dynamic content
- Third-party library internals
核心原则: 测试行为,而非实现细节。
查询优先级(优先使用列表上方的方法):
- — 基于无障碍角色(按钮、标题、文本框)
getByRole - — 带标签的表单元素
getByLabelText - — 带占位符的输入框
getByPlaceholderText - — 可见文本内容
getByText - — 当前表单输入值
getByDisplayValue - — 图片
getByAltText - — 最后选择(使用data-testid属性)
getByTestId
交互操作: 始终使用而非:
userEventfireEventtsx
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 over + for elements that appear asynchronously.
findBy*waitForgetBy*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*waitForgetBy*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, and any context providers needed.MemoryRouter -
Components with router: Usefor components that use
<MemoryRouter initialEntries={["/users/1"]}>oruseParams.useNavigate -
Flaky async tests: Preferover
findBy*+waitFor. If usinggetBy*, increase timeout for CI:waitFor.waitFor(() => ..., { timeout: 5000 }) -
Testing modals/portals: Usequeries (they search the entire document), not
screenqueries.container -
Cleanup: Testing Library auto-cleans after each test. Don't callmanually unless using a custom setup.
cleanup()
See for an annotated test file template.
See for MSW handler patterns.
See for hook testing patterns.
references/component-test-template.tsxreferences/msw-handler-examples.tsreferences/hook-test-template.tsx- 带提供者的组件: 始终使用自定义Render函数,将组件与、
QueryClientProvider及所需的上下文提供者包裹。MemoryRouter - 带路由的组件: 对于使用或
useParams的组件,使用useNavigate。<MemoryRouter initialEntries={["/users/1"]}> - 不稳定的异步测试: 优先使用而非
findBy*+waitFor。如果使用getBy*,在CI环境中增加超时时间:waitFor。waitFor(() => ..., { timeout: 5000 }) - 测试模态框/传送门: 使用查询(会搜索整个文档),而非
screen查询。container - 清理工作: Testing Library会在每个测试后自动清理。除非使用自定义配置,否则无需手动调用。
cleanup()
查看获取带注释的测试文件模板。
查看获取MSW处理器模式示例。
查看获取Hook测试模式示例。
references/component-test-template.tsxreferences/msw-handler-examples.tsreferences/hook-test-template.tsx