FORMA

测试

React 组件测试推荐 React Testing Library(RTL):从用户可见行为断言,而非实现细节。运行器常用 VitestJest

核心概念

RTL 的设计哲学是**「测试软件的使用方式越接近用户的使用方式,测试就越可靠」**。它不提供直接访问组件内部 state/props 的 API,而是强制你通过 DOM 查询(按角色、文本、标签)和模拟用户交互(点击、输入)来编写测试——这样测试更贴近真实使用场景,也更能容忍内部实现的重构。

安装(Vitest 示例)

bash
pnpm add -D vitest @testing-library/react @testing-library/jest-dom jsdom

vite.config.ts 中配置 test.environment: "jsdom"(见 Vitest 文档):

ts
// vite.config.ts
export default defineConfig({
  test: {
    environment: "jsdom",
    setupFiles: "./src/test/setup.ts",
    globals: true,
  },
});
ts
// src/test/setup.ts
import "@testing-library/jest-dom/vitest";

基本用例

tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
import { Counter } from "./Counter";

describe("Counter", () => {
  it("点击后计数增加", async () => {
    const user = userEvent.setup();
    render(<Counter />);
    await user.click(screen.getByRole("button", { name: /加一/i }));
    expect(screen.getByText("1")).toBeInTheDocument();
  });
});

优先使用 role、label、text 查询(getByRolegetByLabelText),与可访问性一致。

查询方法优先级

RTL 官方建议按以下优先级选择查询方式(越靠前越推荐):

  1. getByRole:按 ARIA 角色(button、textbox、heading),最贴近用户/屏幕阅读器的感知方式。
  2. getByLabelText:表单控件配合 <label>
  3. getByPlaceholderText / getByText:无更好选择时的次优方案。
  4. getByTestId最后手段,仅当以上都不适用(如没有语义化角色的容器)。
tsx
// 推荐
screen.getByRole("textbox", { name: "邮箱" });

// 避免(除非无法用语义化查询)
screen.getByTestId("email-input");

测试带依赖注入的组件

tsx
function renderWithProviders(ui: ReactNode) {
  return render(
    <QueryClientProvider client={new QueryClient()}>
      <ThemeProvider>{ui}</ThemeProvider>
    </QueryClientProvider>
  );
}

it("展示用户名", async () => {
  renderWithProviders(<UserProfile userId="1" />);
  expect(await screen.findByText("Test User")).toBeInTheDocument();
});

抽出 renderWithProviders 帮助函数,避免每个测试文件重复包裹相同的 Context/Provider。

异步与副作用

  • findBy*:等待元素出现(内部封装了 waitFor,适合断言异步渲染结果)。
  • waitFor:等待断言条件。
  • 涉及 useEffect 的请求可用 MSW 模拟网络:
ts
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";

const server = setupServer(
  http.get("/api/users/:id", () => HttpResponse.json({ id: "1", name: "Test User" }))
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

MSW 在网络层拦截请求,比手动 mock fetch 更真实,也能在开发环境复用同一套 mock 定义。

快照测试的取舍

tsx
it("匹配快照", () => {
  const { asFragment } = render(<Badge type="success">已完成</Badge>);
  expect(asFragment()).toMatchSnapshot();
});

快照测试对小型、稳定的展示组件(如 Badge、Icon)有一定价值,但大段 DOM 快照难以人工审查、容易被无脑更新("snapshot rubber-stamping"),核心业务逻辑应优先用行为断言而非快照。

避免

  • 依赖组件内部 state 变量名或私有方法。
  • 快照测试滥用(大段 DOM 快照难以维护)。
  • container.querySelector 代替语义化查询(除非确实没有更好的方式)。

最佳实践

  • 测试文件贴近「用户故事」组织:it("输入非法邮箱后显示错误提示"),而不是 it("email state 更新")
  • 表单类组件重点测校验逻辑与提交结果,不测内部 useState 的字段名。
  • 网络请求统一用 MSW 模拟,同一套 handlers 可以在开发环境(mock server)和测试中复用。
  • 组件依赖多个 Context/Provider 时,封装 renderWithProviders 减少重复代码。
  • CI 上锁定 Node 版本与依赖版本,避免 jsdom/浏览器行为差异导致的偶发失败。

常见坑

现象常见原因处理
getByRole 找不到元素但页面上明明有元素缺少正确的 ARIA role 或语义化标签检查是否用了语义化 HTML(<button> 而非 <div onClick>
测试报「更新未包裹在 act(...) 中」异步状态更新未被测试工具等待完成findBy*/waitFor 等待更新完成,而非同步断言
Mock 请求不生效MSW handler 与实际请求 URL/方法不匹配核对拦截规则的 path 与真实请求一致
快照测试频繁失败又被无脑更新快照过大、覆盖了不相关的样式细节缩小快照范围或改用具体断言
测试之间状态互相影响全局 mock(如 MSW handlers)未在每个测试后重置afterEach 清理/重置 mock 状态

延伸阅读

参考文献

以下链接在编写时均可正常访问:

资料说明
Testing Library:ReactRTL 指南
Vitest 文档测试运行器
React:测试 Recipes官方概览
MSW网络请求 Mock
Testing Library:查询优先级查询方式指南

Series

react

15 / 16