React 测试 是做什么的?
全面的 React 测试模式,涵盖行为驱动的组件测试、自定义 Hook 测试、无障碍断言和网络层模拟。
何时激活
- 为 React 组件、自定义 Hook 或页面编写测试
- 为遗留的未测试组件添加测试覆盖
- 从 Enzyme 或类组件时代的模式迁移到 React Testing Library
- 为新 React 项目设置 Vitest 或 Jest
- 在测试中模拟 HTTP 请求
- 断言无障碍违规
- 决定哪些测试属于 RTL、Playwright 组件测试或完整 E2E
核心原则
测试用户所看到和操作的内容,而非实现细节。
一个测试应当:
- 使用与生产环境相同的 Provider 渲染组件
- 通过无障碍查询(role, label)和
userEvent与之交互 - 断言可见的输出和可观察的副作用(回调被调用,请求已发送)
一个测试不应当:
- 检查组件的 state、传递给子组件的 props 或在哪个 Hook 被调用
- 模拟 React 本身或框架 Hook
- 断言渲染次数或超出用户影响的 DOM 结构
测试库选择
| 运行器 | 适用场景 | 备注 |
|---|---|---|
| Vitest | Vite、Remix、现代项目 | 更快,原生 ESM,兼容 Jest API |
| Jest | Next.js、CRA、既有仓库 | 许多 React 项目的默认选择 |
| Playwright 组件测试 | 需要真实浏览器引擎 | 当 JSDOM 缺少所需功能时使用 |
| Cypress 组件测试 | 真实浏览器,且已在使用 Cypress | Playwright CT 的替代方案 |
选择一个。除非有明确的分层分离,否则不要在同一个仓库中同时运行 RTL + Vitest 和 Playwright CT。
查询优先级
React Testing Library 提供了三个层级的查询——请从上到下使用:
- 所有用户可访问:
getByRole、getByLabelText、getByPlaceholderText、getByText、getByDisplayValue - 语义:
getByAltText、getByTitle - 测试 ID(逃生舱):
getByTestId
// 最佳
screen.getByRole("button", { name: /save/i });
// 输入框可行
screen.getByLabelText("Email");
// 最后的手段
screen.getByTestId("save-btn");
变体:
getBy*— 如果没有匹配则抛出异常queryBy*— 返回null(用于“断言不存在”)findBy*— 异步,返回一个 Promise(用于在异步工作后出现的元素)
使用 userEvent 进行用户交互
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"), "[email protected]");
await user.click(screen.getByRole("button", { name: /save/i }));
expect(onSubmit).toHaveBeenCalledWith({ email: "[email protected]" });
});
- 始终
awaituserEvent 调用 - 每个测试调用一次
userEvent.setup(),重复使用返回的user userEvent模拟真实的浏览器序列;fireEvent只分发一个合成事件——优先使用userEvent
异步模式
// 在异步工作后出现的元素
expect(await screen.findByText("Loaded")).toBeInTheDocument();
// 副作用断言
await waitFor(() => expect(saveSpy).toHaveBeenCalled());
// 应该消失的元素
await waitForElementToBeRemoved(() => screen.queryByText("Loading"));
永远不要使用 setTimeout + 断言——不稳定。请使用上述匹配器。
使用 MSW 进行网络模拟
Mock Service Worker 在网络层进行模拟。组件、Hook 和 fetch 库的行为与生产环境完全一致。
设置
// 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",这样任何未模拟的请求都会大声失败测试——静默通过比红色更糟糕。
每个测试的覆盖
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 包装
在 test-utils.tsx 中统一包装 Provider:
// 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"。
自定义 Hook 测试
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中 - 仅通过 Hook 的公共 API 进行测试
- 对于使用上下文的 Hook,传递一个
wrapper
无障碍断言
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)
- 图片缺少 alt 文本
- 标题顺序违规
交叉链接:skills/accessibility/SKILL.md 获取更广泛的无障碍测试策略。
何时不应使用快照测试
渲染输出的快照:
- 每次样式更改都会失效
- 审查时容易被“橡皮章”通过
- 测试的是实现细节(DOM 结构),而非行为
可接受的快照用途:
- 纯数据序列化函数(
formatInvoice(invoice)-> 稳定字符串) - 生成的配置文件(例如,webpack 配置输出)
对于组件的视觉回归,请使用 Playwright/Cypress 截图或 Percy/Chromatic——真正的视觉差异,而非 DOM 字符串。
何时使用 Playwright / Cypress
JSDOM(由 Vitest/Jest 使用)无法:
- 渲染真实布局(flexbox、grid、视口查询)
- 运行原生浏览器动画、CSS 过渡
- 测试滚动行为、拖放、从剪贴板粘贴
- 处理 iframe、弹出窗口、下载、跨源流程
- 在受控环境中运行具有完整 DevTools 支持的真实网络
对于上述任何情况,请使用 Playwright 组件测试(在真实浏览器中的组件测试)或完整 E2E。请参阅 e2e-testing skill。
决策边界:
- Hook、展示性组件、带有逻辑的表单 -> RTL
- 其布局很重要或使用 JSDOM 中没有的浏览器 API 的组件 -> Playwright CT
- 跨多个页面的完整用户流程 -> Playwright/Cypress E2E
覆盖目标
| 层 | 目标 |
|---|---|
| 纯工具函数 | >=90% |
| 自定义 Hook | >=85% |
| 展示性组件 | >=80% — 行为,而非代码行数 |
| 容器组件 | >=70% — 主要路径 + 错误状态 |
| 页面 | E2E 单独覆盖;至少烟雾测试 |
通过 vitest.config.ts / jest.config.js 配置:
// vitest.config.ts
test: {
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
thresholds: {
lines: 80,
functions: 80,
branches: 70,
statements: 80,
},
},
}
反模式
container.querySelector("...")— 绕过无障碍查询,使测试在真实用户可能失败时通过- 断言渲染次数 — 实现细节
jest.mock("react", ...)— 永远不要模拟 React。请重构组件- 默认模拟子组件 — 测试的是集成,而非隔离。仅当子组件有严重的副作用时才模拟
- 忽略
act()警告 — 它们指示真正的 bug(卸载后状态更新、缺少异步包装) - 跨测试共享可变状态 — 当测试顺序改变时导致不稳定
- 移除
it.skip()后通过的测试 — 你的测试并没有真正断言你认为的内容
TDD 工作流
RED -> 为下一个需求编写失败的测试
GREEN -> 编写最少的组件代码以通过测试
REFACTOR -> 改进组件,测试保持绿色
REPEAT -> 下一个需求
对于新组件:
- 定义组件的 prop 类型和签名
- 为最简单的情况编写第一个测试
- 验证它因正确的理由而失败
- 实现刚好足够的代码以通过
- 添加下一个测试用例
- 当第三个相似的测试揭示一个模式时进行重构
测试命令
# Vitest
vitest # 监听模式
vitest run # 单次运行
vitest run --coverage # 带覆盖率
vitest run path/to/file.test.tsx # 单个文件
# Jest
jest --watch
jest --coverage
jest path/to/file.test.tsx
# CI 模式
CI=true vitest run --coverage
相关
- 规则:rules/react/testing.md
- 技能:react-patterns、accessibility、e2e-testing、tdd-workflow
- 代理:
react-reviewer(在代码审查期间检查测试质量)、tdd-guide(强制执行 TDD 流程) - 命令:
/react-test、/react-review
示例
使用 MSW 和 userEvent 进行表单提交
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"), "[email protected]");
await user.click(screen.getByRole("button", { name: /save/i }));
expect(await screen.findByText(/saved successfully/i)).toBeInTheDocument();
});
测试错误边界
function Broken() {
throw new Error("boom");
}
test("错误边界渲染后备 UI", () => {
// 抑制 React 对预期异常的 console.error 噪音,然后恢复
// 以便 spy 不会泄漏到其他测试并隐藏真正的错误。
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();
}
});
测试 Suspense 边界
test("显示加载中然后显示内容", async () => {
renderWithProviders(
<Suspense fallback={<div>Loading...</div>}>
<UserDetail id="1" />
</Suspense>,
);
expect(screen.getByText("Loading...")).toBeInTheDocument();
expect(await screen.findByText("Alice")).toBeInTheDocument();
});