react-testing

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

React 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

库选择

RunnerWhenNote
VitestVite, Remix, modern setupsFaster, native ESM, Jest-compatible API
JestNext.js, CRA, established reposDefault for many React projects
Playwright Component TestingReal browser engine neededUse when JSDOM lacks the required feature
Cypress Component TestingReal browser, Cypress already in useAlternative to Playwright CT
Pick one. Do not run RTL + Vitest AND Playwright CT in the same repo unless you have a clear lane separation.
运行器适用场景说明
VitestVite、Remix、现代项目架构速度更快,原生支持ESM,兼容Jest API
JestNext.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:
  1. Accessible to everyone:
    getByRole
    ,
    getByLabelText
    ,
    getByPlaceholderText
    ,
    getByText
    ,
    getByDisplayValue
  2. Semantic:
    getByAltText
    ,
    getByTitle
  3. 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:
  • getBy*
    — throws if no match
  • queryBy*
    — returns
    null
    (use for "assert absence")
  • findBy*
    — async, returns a Promise (use for elements that appear after async work)
React Testing Library将查询分为三个层级,请自上而下使用:
  1. 面向所有用户的无障碍查询
    getByRole
    getByLabelText
    getByPlaceholderText
    getByText
    getByDisplayValue
  2. 语义化查询
    getByAltText
    getByTitle
  3. 测试ID(应急方案)
    getByTestId
tsx
// 最佳实践
screen.getByRole("button", { name: /save/i });

// 输入框可使用
screen.getByLabelText("Email");

// 最后选择
screen.getByTestId("save-btn");
变体说明:
  • getBy*
    —— 无匹配时抛出错误
  • queryBy*
    —— 返回
    null
    (用于“断言元素不存在”场景)
  • findBy*
    —— 异步方法,返回Promise(用于异步操作后出现的元素)

User Interaction with
userEvent

使用
userEvent
进行用户交互

tsx
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
    await
    userEvent calls
  • Call
    userEvent.setup()
    once per test, reuse the returned
    user
  • userEvent
    simulates a real browser sequence;
    fireEvent
    dispatches a single synthetic event — prefer
    userEvent
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" });
});
  • 始终
    await
    userEvent调用
  • 每个测试中调用一次
    userEvent.setup()
    ,复用返回的
    user
    对象
  • userEvent
    模拟真实浏览器操作序列;
    fireEvent
    仅触发单个合成事件 —— 优先使用
    userEvent

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
setTimeout
+ assertion — flaky. Use the matchers above.
tsx
// 异步操作后出现的元素
expect(await screen.findByText("Loaded")).toBeInTheDocument();

// 副作用断言
await waitFor(() => expect(saveSpy).toHaveBeenCalled());

// 应消失的元素
await waitForElementToBeRemoved(() => screen.queryByText("Loading"));
绝不要使用
setTimeout
+ 断言 —— 测试会不稳定。请使用上述匹配器。

Network 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
onUnhandledRequest: "error"
so any unmocked request fails the test loudly — silent passes are worse than red.
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.tsx
:
tsx
// 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
import { renderWithProviders, screen } from "test-utils"
in every test file.
test-utils.tsx
中统一包装提供者:
tsx
// 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 (
    formatInvoice(invoice)
    -> stable string)
  • 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

覆盖率目标

LayerTarget
Pure utilities>=90%
Custom hooks>=85%
Presentational components>=80% — behavior, not lines
Container components>=70% — golden paths + error states
PagesE2E covered separately; smoke test minimum
Configure via
vitest.config.ts
/
jest.config.js
:
ts
// 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.ts
/
jest.config.js
配置:
ts
// vitest.config.ts
test: {
  coverage: {
    provider: "v8",
    reporter: ["text", "html", "lcov"],
    thresholds: {
      lines: 80,
      functions: 80,
      branches: 70,
      statements: 80,
    },
  },
}

Anti-Patterns

反模式

  • container.querySelector("...")
    — bypasses accessibility queries, lets tests pass when real users would fail
  • Asserting on number of renders — implementation detail
  • jest.mock("react", ...)
    — never mock React. Refactor the component instead
  • Mocking child components by default — tests the integration, not isolation. Mock only when the child has heavy side effects
  • Ignoring
    act()
    warnings — they signal real bugs (state update after unmount, missing async wrapping)
  • Sharing mutable state across tests — flakes when test order changes
  • Tests that pass with
    it.skip()
    removed — your test does not actually assert what you think
  • container.querySelector("...")
    —— 绕过无障碍查询,导致测试通过但真实用户无法操作
  • 断言渲染次数 —— 实现细节
  • jest.mock("react", ...)
    —— 绝不要模拟React。应重构组件
  • 默认模拟子组件 —— 测试的是集成而非隔离。仅当子组件有大量副作用时才模拟
  • 忽略
    act()
    警告 —— 这些警告表明存在真实bug(卸载后更新状态、缺少异步包装)
  • 跨测试共享可变状态 —— 测试顺序变化时会导致不稳定
  • 移除
    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 requirement
For new components:
  1. Define the component's prop type and signature
  2. Write the first test for the simplest case
  3. Verify it fails for the right reason
  4. Implement just enough to pass
  5. Add the next test case
  6. Refactor when the third similar test reveals a pattern
RED     -> 为下一个需求编写失败的测试
GREEN   -> 编写最少的组件代码使测试通过
REFACTOR -> 优化组件,保持测试通过
REPEAT  -> 处理下一个需求
对于新组件:
  1. 定义组件的prop类型和签名
  2. 为最简单的场景编写第一个测试
  3. 验证测试因正确的原因失败
  4. 实现刚好足够的代码使测试通过
  5. 添加下一个测试用例
  6. 当第三个相似测试揭示出模式时进行重构

Test Commands

测试命令

bash
undefined
bash
undefined

Vitest

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
undefined
CI=true vitest run --coverage
undefined

Related

相关资源

  • Rules: rules/react/testing.md
  • Skills: react-patterns, accessibility, e2e-testing, tdd-workflow
  • Agents:
    react-reviewer
    (reviews test quality during code review),
    tdd-guide
    (enforces TDD process)
  • Commands:
    /react-test
    ,
    /react-review
  • 规则:rules/react/testing.md
  • 技能:react-patterns, accessibility, e2e-testing, tdd-workflow
  • Agents:
    react-reviewer
    (代码评审期间检查测试质量),
    tdd-guide
    (强制执行TDD流程)
  • 命令:
    /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();
});