React测试 是做什么的?
全面的React测试模式,涵盖行为驱动的组件测试、自定义Hook测试、无障碍断言和网络层模拟。
何时激活
- 为React组件、自定义Hook或页面编写测试
- 为遗留的未测试组件添加测试覆盖
- 从Enzyme或类组件时代的模式迁移到React Testing Library
- 为新的React项目设置Vitest或Jest
- 在测试中模拟HTTP请求
- 断言无障碍违规
- 决定哪些测试属于RTL、Playwright Component Testing还是完整的端到端测试
核心原则
测试用户所见和所操作的内容,而非实现细节。
测试应该:
- 使用与生产环境相同的Provider渲染组件
- 通过可访问的查询方式(role, label)和
userEvent进行交互 - 断言可见的输出和可观察的副作用(回调被调用、请求已发送)
测试不应该:
- 检查组件状态、传递给子组件的props或调用了哪些Hook
- 模拟React本身或框架Hook
- 断言渲染次数或影响用户的DOM结构之外的细节
库选择
| 运行器 | 何时使用 | 备注 |
|---|---|---|
| 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。
查询优先级
React Testing Library 暴露了三个层次的查询方法——请自上而下使用:
- 对所有人可访问的:
getByRole、getByLabelText、getByPlaceholderText、getByText、getByDisplayValue - 语义的:
getByAltText、getByTitle - Test 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]" });
});
- 始终对userEvent调用使用
await - 每个测试调用一次
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 () => {
// 在wrapper外部每个测试只实例化一次QueryClient,以便它能在重新渲染时保持状态。
// 在wrapper闭包内创建它会在每次渲染时重置缓存状态,导致测试不稳定。
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进行测试
- 对于使用Context的Hook,传递
wrapper
无障碍断言
import { axe, toHaveNoViolations } from "jest-axe"; // 或 vitest-axe
expect.extend(toHaveNoViolations);
test("UserCard 没有a11y违规", async () => {
const { container } = render(<UserCard user={mockUser} />);
expect(await axe(container)).toHaveNoViolations();
});
在组件测试中为每个交互式组件运行axe。它能捕获:
- 表单输入缺少标签
- 无效的ARIA用法
- 颜色对比度差(有限制——JSDOM没有真正的CSS引擎,因此仅对内联样式有效;视觉对比度属于Playwright)
- 图片缺少替代文本
- 标题顺序违规
交叉链接:skills/accessibility/SKILL.md 了解更广泛的无障碍测试手册。
何时不使用快照测试
渲染输出的快照:
- 每次样式更改都会破坏
- 在审查期间容易被敷衍通过
- 测试实现细节(DOM结构),而非行为
可接受的快照使用场景:
- 纯数据序列化函数(
formatInvoice(invoice)-> 稳定字符串) - 生成的配置文件(例如webpack配置输出)
对于组件的视觉回归,请使用Playwright/Cypress截图或Percy/Chromatic——实际的视觉差异,而非DOM字符串。
何时使用Playwright / Cypress
JSDOM(Vitest/Jest使用)不能:
- 渲染真实的布局(flexbox, grid, 视口查询)
- 运行原生浏览器动画、CSS过渡
- 测试滚动行为、拖放、从剪贴板粘贴
- 处理iframe、弹窗、下载、跨域流程
- 在具有完整DevTools支持的受控环境中运行真实网络
对于上述任何情况,请使用Playwright Component Testing(真实浏览器中的组件测试)或完整的E2E测试。参见e2e-testing技能。
决策边界:
- 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输出(对于预期的抛出是正常的),然后恢复
// 以便间谍不会泄漏到其他测试并隐藏真实的错误。
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();
});