react-testing
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseReact Testing
React测试
Comprehensive React testing patterns for behavior-focused component tests, custom hook tests, accessibility assertions, and network-level mocking.
本文提供全面的React测试模式,包括面向行为的组件测试、自定义钩子测试、无障碍断言以及网络层模拟。
When to Activate
适用场景
- Writing tests for React components, custom hooks, or pages
- Adding test coverage to legacy untested components
- Migrating from Enzyme or class-component-era patterns to React Testing Library
- Setting up Vitest or Jest for a new React project
- Mocking HTTP requests in tests
- Asserting accessibility violations
- Deciding which tests belong in RTL vs Playwright Component Testing vs full E2E
- 为React组件、自定义钩子或页面编写测试
- 为遗留未测试组件添加测试覆盖率
- 从Enzyme或类组件时代的测试模式迁移至React Testing Library
- 为新React项目配置Vitest或Jest
- 在测试中模拟HTTP请求
- 断言无障碍违规问题
- 判定测试应归属RTL、Playwright组件测试还是完整端到端测试
Core Principle
核心原则
Test what the user sees and does, not implementation details.
A test should:
- Render the component with the same providers it has in production
- Interact with it via accessible queries (role, label) and
userEvent - Assert visible output and observable side effects (callback fired, request sent)
A test should NOT:
- Inspect component state, props passed to children, or which hooks were called
- Mock React itself or framework hooks
- Assert on the number of renders or DOM structure beyond what affects users
测试用户所见和所做的行为,而非实现细节。
测试应:
- 使用组件在生产环境中相同的提供者进行渲染
- 通过无障碍查询(role、label)和与组件交互
userEvent - 断言可见输出和可观察的副作用(回调触发、请求发送)
测试不应:
- 检查组件状态、传递给子组件的props或调用了哪些钩子
- 模拟React本身或框架钩子
- 断言渲染次数或超出用户影响范围的DOM结构
Library Choice
库选择
| Runner | When | Note |
|---|---|---|
| Vitest | Vite, Remix, modern setups | Faster, native ESM, Jest-compatible API |
| Jest | Next.js, CRA, established repos | Default for many React projects |
| Playwright Component Testing | Real browser engine needed | Use when JSDOM lacks the required feature |
| Cypress Component Testing | Real browser, Cypress already in use | Alternative to Playwright CT |
Pick one. Do not run RTL + Vitest AND Playwright CT in the same repo unless you have a clear lane separation.
| 运行器 | 适用场景 | 说明 |
|---|---|---|
| Vitest | Vite、Remix、现代项目架构 | 速度更快,原生支持ESM,兼容Jest API |
| Jest | Next.js、CRA、成熟仓库 | 许多React项目的默认选择 |
| Playwright Component Testing | 需要真实浏览器引擎时 | 当JSDOM缺乏所需功能时使用 |
| Cypress Component Testing | 需要真实浏览器且已在使用Cypress时 | Playwright CT的替代方案 |
选择其中一种即可。除非有明确的职责划分,否则不要在同一仓库中同时运行RTL + Vitest和Playwright CT。
Query Priority
查询优先级
React Testing Library exposes queries in three tiers — use top-down:
- Accessible to everyone: ,
getByRole,getByLabelText,getByPlaceholderText,getByTextgetByDisplayValue - Semantic: ,
getByAltTextgetByTitle - Test IDs (escape hatch):
getByTestId
tsx
// Best
screen.getByRole("button", { name: /save/i });
// OK for inputs
screen.getByLabelText("Email");
// Last resort
screen.getByTestId("save-btn");Variants:
- — throws if no match
getBy* - — returns
queryBy*(use for "assert absence")null - — async, returns a Promise (use for elements that appear after async work)
findBy*
React Testing Library将查询分为三个层级,请自上而下使用:
- 面向所有用户的无障碍查询:、
getByRole、getByLabelText、getByPlaceholderText、getByTextgetByDisplayValue - 语义化查询:、
getByAltTextgetByTitle - 测试ID(应急方案):
getByTestId
tsx
// 最佳实践
screen.getByRole("button", { name: /save/i });
// 输入框可使用
screen.getByLabelText("Email");
// 最后选择
screen.getByTestId("save-btn");变体说明:
- —— 无匹配时抛出错误
getBy* - —— 返回
queryBy*(用于“断言元素不存在”场景)null - —— 异步方法,返回Promise(用于异步操作后出现的元素)
findBy*
User Interaction with userEvent
userEvent使用userEvent
进行用户交互
userEventtsx
import userEvent from "@testing-library/user-event";
test("submits the form", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<UserForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText("Email"), "user@example.com");
await user.click(screen.getByRole("button", { name: /save/i }));
expect(onSubmit).toHaveBeenCalledWith({ email: "user@example.com" });
});- Always userEvent calls
await - Call once per test, reuse the returned
userEvent.setup()user - simulates a real browser sequence;
userEventdispatches a single synthetic event — preferfireEventuserEvent
tsx
import userEvent from "@testing-library/user-event";
test("提交表单", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<UserForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText("Email"), "user@example.com");
await user.click(screen.getByRole("button", { name: /save/i }));
expect(onSubmit).toHaveBeenCalledWith({ email: "user@example.com" });
});- 始终userEvent调用
await - 每个测试中调用一次,复用返回的
userEvent.setup()对象user - 模拟真实浏览器操作序列;
userEvent仅触发单个合成事件 —— 优先使用fireEventuserEvent
Async Patterns
异步模式
tsx
// Element that appears after async work
expect(await screen.findByText("Loaded")).toBeInTheDocument();
// Side effect assertion
await waitFor(() => expect(saveSpy).toHaveBeenCalled());
// Element that should disappear
await waitForElementToBeRemoved(() => screen.queryByText("Loading"));Never + assertion — flaky. Use the matchers above.
setTimeouttsx
// 异步操作后出现的元素
expect(await screen.findByText("Loaded")).toBeInTheDocument();
// 副作用断言
await waitFor(() => expect(saveSpy).toHaveBeenCalled());
// 应消失的元素
await waitForElementToBeRemoved(() => screen.queryByText("Loading"));绝不要使用 + 断言 —— 测试会不稳定。请使用上述匹配器。
setTimeoutNetwork Mocking with MSW
使用MSW进行网络模拟
Mock Service Worker mocks at the network layer. The component, hooks, and fetch library all behave exactly as in production.
Mock Service Worker在网络层进行模拟。组件、钩子和请求库的行为与生产环境完全一致。
Setup
配置
ts
// test/setup.ts
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/users/:id", ({ params }) =>
HttpResponse.json({ id: params.id, name: "Alice" }),
),
http.post("/api/users", async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: "new-id", ...body }, { status: 201 });
}),
];
export const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());Configure so any unmocked request fails the test loudly — silent passes are worse than red.
onUnhandledRequest: "error"ts
// test/setup.ts
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/users/:id", ({ params }) =>
HttpResponse.json({ id: params.id, name: "Alice" }),
),
http.post("/api/users", async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: "new-id", ...body }, { status: 201 });
}),
];
export const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());配置,这样任何未被模拟的请求都会直接导致测试失败 —— 静默通过比失败更糟糕。
onUnhandledRequest: "error"Per-test override
单测试覆盖
tsx
test("renders error on 500", async () => {
server.use(
http.get("/api/users/:id", () => new HttpResponse(null, { status: 500 })),
);
render(<UserPage id="1" />);
expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument();
});tsx
test("500错误时渲染错误提示", async () => {
server.use(
http.get("/api/users/:id", () => new HttpResponse(null, { status: 500 })),
);
render(<UserPage id="1" />);
expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument();
});Provider Wrapping
提供者包装
Wrap providers once in a :
test-utils.tsxtsx
// test-utils.tsx
import { render, RenderOptions } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
export function renderWithProviders(
ui: React.ReactElement,
options?: RenderOptions,
) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={lightTheme}>
<MemoryRouter>{ui}</MemoryRouter>
</ThemeProvider>
</QueryClientProvider>,
options,
);
}
export * from "@testing-library/react";Then in every test file.
import { renderWithProviders, screen } from "test-utils"在中统一包装提供者:
test-utils.tsxtsx
// test-utils.tsx
import { render, RenderOptions } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
export function renderWithProviders(
ui: React.ReactElement,
options?: RenderOptions,
) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={lightTheme}>
<MemoryRouter>{ui}</MemoryRouter>
</ThemeProvider>
</QueryClientProvider>,
options,
);
}
export * from "@testing-library/react";然后在每个测试文件中。
import { renderWithProviders, screen } from "test-utils"Custom Hook Testing
自定义钩子测试
tsx
import { renderHook, act } from "@testing-library/react";
test("useCounter increments and decrements", () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => result.current.increment());
expect(result.current.count).toBe(1);
act(() => result.current.decrement());
expect(result.current.count).toBe(0);
});
test("useCounter accepts initial value", () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test("useUser fetches user data", async () => {
// Instantiate QueryClient ONCE per test outside the wrapper so it survives re-renders.
// Creating it inside the wrapper closure resets cache state on every render, producing flaky tests.
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useUser("1"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({ id: "1", name: "Alice" });
});- Wrap state-changing calls in
act - Test through the hook's public API only
- For hooks that use context, pass a
wrapper
tsx
import { renderHook, act } from "@testing-library/react";
test("useCounter实现增减计数", () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => result.current.increment());
expect(result.current.count).toBe(1);
act(() => result.current.decrement());
expect(result.current.count).toBe(0);
});
test("useCounter接受初始值", () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test("useUser获取用户数据", async () => {
// 在包装器外每个测试实例化一次QueryClient,使其在重新渲染时保持状态。
// 在包装器闭包内创建会在每次渲染时重置缓存状态,导致测试不稳定。
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useUser("1"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({ id: "1", name: "Alice" });
});- 将状态变更调用包裹在中
act - 仅通过钩子的公开API进行测试
- 对于使用上下文的钩子,传递
wrapper
Accessibility Assertions
无障碍断言
tsx
import { axe, toHaveNoViolations } from "jest-axe"; // or vitest-axe
expect.extend(toHaveNoViolations);
test("UserCard has no a11y violations", async () => {
const { container } = render(<UserCard user={mockUser} />);
expect(await axe(container)).toHaveNoViolations();
});Run axe in component tests for every interactive component. Catches:
- Missing labels on form inputs
- Invalid ARIA usage
- Poor color contrast (limited — JSDOM has no real CSS engine, so this works for inline styles only; visual contrast belongs in Playwright)
- Missing alt text on images
- Heading order violations
Cross-link: skills/accessibility/SKILL.md for the broader a11y testing playbook.
tsx
import { axe, toHaveNoViolations } from "jest-axe"; // 或vitest-axe
expect.extend(toHaveNoViolations);
test("UserCard无无障碍违规", async () => {
const { container } = render(<UserCard user={mockUser} />);
expect(await axe(container)).toHaveNoViolations();
});为每个交互式组件在组件测试中运行axe。可捕获:
- 表单输入缺少标签
- 无效的ARIA使用
- 较差的颜色对比度(有限制——JSDOM没有真实的CSS引擎,因此仅对内联样式有效;视觉对比度测试应在Playwright中进行)
- 图片缺少替代文本
- 标题顺序违规
交叉链接:skills/accessibility/SKILL.md 查看更全面的无障碍测试指南。
When NOT to Use Snapshot Tests
不应使用快照测试的场景
Snapshots of rendered output:
- Break on every styling change
- Get rubber-stamped during review
- Test implementation detail (DOM structure), not behavior
Acceptable snapshot uses:
- Pure data serialization functions (-> stable string)
formatInvoice(invoice) - Generated config files (e.g., webpack config output)
For visual regression on components, use Playwright/Cypress screenshots or Percy/Chromatic — actual visual diffs, not DOM strings.
渲染输出的快照:
- 每次样式变更都会失效
- 评审时容易被草率通过
- 测试实现细节(DOM结构),而非行为
可接受的快照使用场景:
- 纯数据序列化函数(-> 稳定字符串)
formatInvoice(invoice) - 生成的配置文件(如webpack配置输出)
对于组件的视觉回归测试,使用Playwright/Cypress截图或Percy/Chromatic——实际的视觉差异对比,而非DOM字符串。
When to Reach for Playwright / Cypress
何时选择Playwright / Cypress
JSDOM (used by Vitest/Jest) cannot:
- Render real layout (flexbox, grid, viewport queries)
- Run native browser animation, CSS transitions
- Test scrolling behavior, drag-and-drop, paste from clipboard
- Handle iframes, popups, downloads, cross-origin flows
- Run real network in a controlled environment with full DevTools support
For any of those, use Playwright Component Testing (component test in real browser) or full E2E. See e2e-testing skill.
Decision boundary:
- A hook, a presentational component, a form with logic -> RTL
- A component whose layout matters or that uses browser APIs not in JSDOM -> Playwright CT
- A full user flow across multiple pages -> Playwright/Cypress E2E
JSDOM(Vitest/Jest使用)无法:
- 渲染真实布局(flexbox、grid、视口查询)
- 运行原生浏览器动画、CSS过渡
- 测试滚动行为、拖放、剪贴板粘贴
- 处理iframe、弹窗、下载、跨源流程
- 在受控环境中运行真实网络并支持完整DevTools
对于上述任何场景,请使用Playwright组件测试(在真实浏览器中进行组件测试)或完整端到端测试。查看e2e-testing技能文档。
判定边界:
- 钩子、展示组件、带逻辑的表单 -> RTL
- 布局重要或使用JSDOM不支持的浏览器API的组件 -> Playwright CT
- 跨多个页面的完整用户流程 -> Playwright/Cypress端到端测试
Coverage Targets
覆盖率目标
| Layer | Target |
|---|---|
| Pure utilities | >=90% |
| Custom hooks | >=85% |
| Presentational components | >=80% — behavior, not lines |
| Container components | >=70% — golden paths + error states |
| Pages | E2E covered separately; smoke test minimum |
Configure via / :
vitest.config.tsjest.config.jsts
// vitest.config.ts
test: {
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
thresholds: {
lines: 80,
functions: 80,
branches: 70,
statements: 80,
},
},
}| 层级 | 目标 |
|---|---|
| 纯工具函数 | >=90% |
| 自定义钩子 | >=85% |
| 展示组件 | >=80% —— 基于行为,而非代码行数 |
| 容器组件 | >=70% —— 主流程 + 错误状态 |
| 页面 | 由端到端测试单独覆盖;至少进行冒烟测试 |
通过 / 配置:
vitest.config.tsjest.config.jsts
// vitest.config.ts
test: {
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
thresholds: {
lines: 80,
functions: 80,
branches: 70,
statements: 80,
},
},
}Anti-Patterns
反模式
- — bypasses accessibility queries, lets tests pass when real users would fail
container.querySelector("...") - Asserting on number of renders — implementation detail
- — never mock React. Refactor the component instead
jest.mock("react", ...) - Mocking child components by default — tests the integration, not isolation. Mock only when the child has heavy side effects
- Ignoring warnings — they signal real bugs (state update after unmount, missing async wrapping)
act() - Sharing mutable state across tests — flakes when test order changes
- Tests that pass with removed — your test does not actually assert what you think
it.skip()
- —— 绕过无障碍查询,导致测试通过但真实用户无法操作
container.querySelector("...") - 断言渲染次数 —— 实现细节
- —— 绝不要模拟React。应重构组件
jest.mock("react", ...) - 默认模拟子组件 —— 测试的是集成而非隔离。仅当子组件有大量副作用时才模拟
- 忽略警告 —— 这些警告表明存在真实bug(卸载后更新状态、缺少异步包装)
act() - 跨测试共享可变状态 —— 测试顺序变化时会导致不稳定
- 移除后测试仍通过 —— 你的测试并未真正断言你预期的内容
it.skip()
TDD Workflow
TDD工作流
RED -> Write failing test for the next requirement
GREEN -> Write minimal component code to pass
REFACTOR -> Improve the component, tests stay green
REPEAT -> Next requirementFor new components:
- Define the component's prop type and signature
- Write the first test for the simplest case
- Verify it fails for the right reason
- Implement just enough to pass
- Add the next test case
- Refactor when the third similar test reveals a pattern
RED -> 为下一个需求编写失败的测试
GREEN -> 编写最少的组件代码使测试通过
REFACTOR -> 优化组件,保持测试通过
REPEAT -> 处理下一个需求对于新组件:
- 定义组件的prop类型和签名
- 为最简单的场景编写第一个测试
- 验证测试因正确的原因失败
- 实现刚好足够的代码使测试通过
- 添加下一个测试用例
- 当第三个相似测试揭示出模式时进行重构
Test Commands
测试命令
bash
undefinedbash
undefinedVitest
Vitest
vitest # watch
vitest run # one-shot
vitest run --coverage # with coverage
vitest run path/to/file.test.tsx # single file
vitest # 监听模式
vitest run # 单次运行
vitest run --coverage # 生成覆盖率报告
vitest run path/to/file.test.tsx # 运行单个文件测试
Jest
Jest
jest --watch
jest --coverage
jest path/to/file.test.tsx
jest --watch
jest --coverage
jest path/to/file.test.tsx
CI mode
CI模式
CI=true vitest run --coverage
undefinedCI=true vitest run --coverage
undefinedRelated
相关资源
- Rules: rules/react/testing.md
- Skills: react-patterns, accessibility, e2e-testing, tdd-workflow
- Agents: (reviews test quality during code review),
react-reviewer(enforces TDD process)tdd-guide - Commands: ,
/react-test/react-review
- 规则:rules/react/testing.md
- 技能:react-patterns, accessibility, e2e-testing, tdd-workflow
- Agents: (代码评审期间检查测试质量),
react-reviewer(强制执行TDD流程)tdd-guide - 命令: ,
/react-test/react-review
Examples
示例
Form submission with MSW and userEvent
使用MSW和userEvent测试表单提交
tsx
test("submits user form and shows success", async () => {
server.use(
http.post("/api/users", () =>
HttpResponse.json({ id: "1", name: "Alice" }, { status: 201 }),
),
);
const user = userEvent.setup();
renderWithProviders(<UserForm />);
await user.type(screen.getByLabelText("Name"), "Alice");
await user.type(screen.getByLabelText("Email"), "alice@example.com");
await user.click(screen.getByRole("button", { name: /save/i }));
expect(await screen.findByText(/saved successfully/i)).toBeInTheDocument();
});tsx
test("提交用户表单并显示成功提示", async () => {
server.use(
http.post("/api/users", () =>
HttpResponse.json({ id: "1", name: "Alice" }, { status: 201 }),
),
);
const user = userEvent.setup();
renderWithProviders(<UserForm />);
await user.type(screen.getByLabelText("Name"), "Alice");
await user.type(screen.getByLabelText("Email"), "alice@example.com");
await user.click(screen.getByRole("button", { name: /save/i }));
expect(await screen.findByText(/saved successfully/i)).toBeInTheDocument();
});Testing an error boundary
测试错误边界
tsx
function Broken() {
throw new Error("boom");
}
test("error boundary renders fallback", () => {
// Suppress React's console.error noise for the expected throw, then restore so
// the spy does not leak across tests and hide real errors elsewhere.
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
render(
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<Broken />
</ErrorBoundary>,
);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
} finally {
errorSpy.mockRestore();
}
});tsx
function Broken() {
throw new Error("boom");
}
test("错误边界渲染回退内容", () => {
// 抑制React针对预期抛出的console.error日志,然后恢复,
// 避免间谍函数跨测试泄漏并隐藏其他地方的真实错误。
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
render(
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<Broken />
</ErrorBoundary>,
);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
} finally {
errorSpy.mockRestore();
}
});Testing a Suspense boundary
测试Suspense边界
tsx
test("shows loading then content", async () => {
renderWithProviders(
<Suspense fallback={<div>Loading...</div>}>
<UserDetail id="1" />
</Suspense>,
);
expect(screen.getByText("Loading...")).toBeInTheDocument();
expect(await screen.findByText("Alice")).toBeInTheDocument();
});tsx
test("显示加载状态然后展示内容", async () => {
renderWithProviders(
<Suspense fallback={<div>Loading...</div>}>
<UserDetail id="1" />
</Suspense>,
);
expect(screen.getByText("Loading...")).toBeInTheDocument();
expect(await screen.findByText("Alice")).toBeInTheDocument();
});