模块三 · 路由、测试与服务端
测试:Vitest、RTL 与 Playwright 的分工
测试的价值不在覆盖率,而在"改错时能不能拦住"。这一节讲清 RTL 的查询哲学、Hook 测试与 Mock 边界,以及让 E2E 不摇的四个前提。
读完这一节,你能做到
- 按测试金字塔分配三类测试,不为小函数写 E2E。
- 用 RTL 写出"像用户一样"的测试,而不是绑定实现细节。
- 用
renderHook测自定义 Hook,用 MSW 管住网络。 - 让 Playwright 用例稳定运行,而不是靠
sleep。
一、三层测试的边界
| 层次 | 工具 | 测什么 | 不该测什么 |
|---|---|---|---|
| 单元 | Vitest | 纯函数、reducer、工具方法 | 组件渲染 |
| 组件 | Vitest + RTL | 用户看到什么、交互后发生什么 | 内部 state 结构、私有方法 |
| 端到端 | Playwright | 登录、下单等关键流程 | 每个字段的校验分支 |
一条经验:组件测试写得像用户操作,单元测试写得像数学证明。两者的失败信息都应该直接指向问题,而不是"某个 mock 被调用了三次"。
二、RTL 的核心:按可访问性查询
RTL 的查询优先级不是风格建议,而是"用户怎么找到这个元素":
getByRole → getByLabelText → getByPlaceholderText → getByText → getByDisplayValue → getByTestId
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
it("提交时带上输入内容", async () => {
const onSubmit = vi.fn();
render(<SearchBox onSubmit={onSubmit} />);
await userEvent.type(screen.getByRole("searchbox"), "订单");
await userEvent.click(screen.getByRole("button", { name: "搜索" }));
expect(onSubmit).toHaveBeenCalledWith("订单");
});
四条实践:
- 优先
getByRole:它同时验证了元素的可访问性,写测试顺带做了无障碍检查。 - 用
userEvent而不是fireEvent:前者模拟真实的聚焦、输入、点击序列,能暴露onBlur时序问题。 - 异步用
findBy*或waitFor,不要await new Promise(r => setTimeout(r, 100))。 getByTestId是最后手段:只在没有语义可用时使用,比如图表容器。
三、组件测试只断言契约
it("已退款订单不显示退款按钮", () => {
render(<OrderRow order={{ ...order, status: "refunded" }} />);
expect(
screen.queryByRole("button", { name: "退款" }),
).not.toBeInTheDocument();
});
it("点击退款发出事件并带上 id", async () => {
const onRefund = vi.fn();
render(<OrderRow order={order} onRefund={onRefund} />);
await userEvent.click(screen.getByRole("button", { name: "退款" }));
expect(onRefund).toHaveBeenCalledWith(order.id);
});
三件事不要测:
- 内部
useState的值(改实现就红)。 - class 名与 DOM 层级(改样式就红)。
- 组件调用了哪些 Hook(那是实现细节)。
四、自定义 Hook 测试
import { renderHook, act } from "@testing-library/react";
it("useCounter 按步长递增", () => {
const { result } = renderHook(() => useCounter(1, 2));
act(() => result.current.inc());
expect(result.current.count).toBe(3);
expect(result.current.double).toBe(6);
});
三个要点:
- 修改状态必须包在
act里,否则会有"未包裹的更新"警告。 - 涉及异步的 Hook 用
await waitFor(() => expect(result.current.status).toBe("success"))。 - 依赖 Context 的 Hook 要用
wrapper提供 Provider,否则拿到默认值。
五、Mock 的边界
| 对象 | 做法 | 注意 |
|---|---|---|
| 网络 | MSW 拦截请求 | 最接近真实,推荐 |
| 模块函数 | vi.mock | 只 mock 边界(如埋点、文件上传) |
| 定时器 | vi.useFakeTimers() | 用完必须 useRealTimers() |
| 路由 | MemoryRouter | 不要 mock useNavigate |
反面写法:
vi.mock("./api", () => ({ fetchOrders: vi.fn().mockResolvedValue([...]) }));
这种 mock 在接口字段改名后仍然"测试通过",因为测试根本没走真实请求结构。用 MSW 时,至少请求路径与响应结构是显式写出来的,接口变更会更容易被发现。
六、E2E 稳定的四个前提
| 前提 | 做法 | 不做的后果 |
|---|---|---|
| 选择器稳定 | 用 role + name,或 data-testid | 改样式就红 |
| 等待条件而非时间 | expect(locator).toBeVisible() 自动重试 | sleep(2000) 在 CI 上不够 |
| 数据隔离 | 每个用例建自己的数据,或重置库 | 用例相互污染,顺序一变就挂 |
| 登录复用 | 用 storageState 保存登录态 | 每个用例都走一遍登录,慢且易失败 |
test("创建订单后出现在列表首行", async ({ page }) => {
await page.goto("/orders");
await page.getByRole("button", { name: "新建订单" }).click();
await page.getByLabel("客户").fill("测试客户");
await page.getByRole("button", { name: "保存" }).click();
await expect(page.getByRole("row").nth(1)).toContainText("测试客户");
});
E2E 只覆盖 3-5 条主流程。用它补组件测试的缺口,会让 CI 变成"随机红"。
七、覆盖率怎么读
| 指标 | 含义 | 陷阱 |
|---|---|---|
| 行覆盖 | 执行过的行占比 | 只调用 getter 也能很高 |
| 分支覆盖 | if/else 分支覆盖 | 更重要,但仍不看断言质量 |
| 函数覆盖 | 函数被调用比例 | 与业务价值无关 |
覆盖率用来找"完全没测的模块",不是用来当 KPI。一个 60% 覆盖但覆盖了空态、错误态、权限分支的项目,比 90% 全测渲染的项更可靠。
八、常见坑
- 用
container.querySelector选元素,改结构就红。 fireEvent.change代替userEvent.type,测不出受控组件的输入问题。- 异步断言用固定
setTimeout,CI 上随机失败。 - mock 掉整个请求模块,接口改了测试仍绿。
- 每个 E2E 都重新登录,套件慢到没人跑。
- 用例之间共享数据库,顺序一变就挂。
- 只测 happy path,错误分支与空态零覆盖。
- 用快照测试整页 DOM,任何改动都要更新快照,最后没人看 diff。
九、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 给一个
reducer写三条用例:加载、成功、失败。 - 给一个搜索组件写组件测试:输入、回车、清空,全部用
getByRole。 - 用
renderHook测一个带异步请求的 Hook,用 MSW 返回固定数据。 - 写一条 Playwright 用例覆盖"登录 → 列表 → 打开详情",要求不出现
sleep。
② 让 AI 写同一题
把第 2 题(搜索组件)交给 AI,补一句 用 getByRole 查询,不许 mock 组件内部的自定义 Hook。
③ 对比要盯的点
- 它有没有
vi.mock掉自定义 Hook?那测的就不是组件行为,而是你的 mock。 - 查询用的是
getByRole,还是container.querySelector?后者把测试绑在实现上。 - 异步它是
findBy/waitFor,还是setTimeout硬等?后者在 CI 上一定抖。
④ 一句话取舍
它写的这条测试,在你“只改实现不改行为”时会不会红?红了说明它测错了什么?
十、自测
- 三层测试各自适合测什么?
- RTL 的查询优先级为什么从 role 开始?
userEvent与fireEvent的区别是什么?- 为什么推荐 MSW 而不是 mock 请求模块?
- 覆盖率为什么不能当 KPI?
参考答案要点:单元测逻辑、组件测契约、E2E 测主流程;按用户与辅助技术找元素;前者模拟真实交互序列;保留真实请求结构;不看断言质量与分支价值。
附:测试文件组织与 CI 配置
测试写得多不如放对位置。推荐与源码同目录,文件名用 .test.ts(x):
src/
├─ features/order/
│ ├─ reducer.ts
│ ├─ reducer.test.ts # 单元
│ ├─ OrderRow.tsx
│ └─ OrderRow.test.tsx # 组件
└─ e2e/
└─ order.spec.ts # E2E
同目录的好处是“改代码时能看到测试”,剥离成本低;缺点是构建时要排除测试文件。Vite 默认会通过 test.include 排除,无需额外配置。
CI 里的最小配置:
// vitest.config.ts
export default defineConfig({
test: {
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
coverage: { reporter: ["text", "lcov"] },
},
});
setup.ts 里放三类全局准备:@testing-library/jest-dom 的断言扩展、afterEach(cleanup)、以及统一的 MSW server 启动与关闭。没有 cleanup,上一个用例的 DOM 会残留,getByRole 可能拿到两个元素而报错。
三条 CI 实践:
- 单测与类型检查分开跑,失败信息更清晰。
- 测试只跑受影响范围:用
vitest --changed或按目录分批,大仓不必全量。 - E2E 用预览环境,不要在开发服务器上跑;开发服务器的 HMR 会让断言不稳定。
最后一个常被忽略的点:测试也要能本地一键复现。如果只能在 CI 上跑,开发时就不会有人跑,最后变成“合并前才红”。
测试的维护成本与断言方式直接相关:断言用户可见行为,重构时几乎不用改;断言内部结构,每次实现调整都要跟着改,最后被删掉。写测试前先问一句“这行断言在重构后还成立吗”,能省掉大量后续维护。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
- ★★★☆☆ React 项目怎么做单元测试、组件测试和 E2E?
- ★★★☆☆ RTL 应该怎么查询元素?
- ★★★☆☆ E2E 测试怎么避免不稳定?
提示:读完可以顺手做几道对应题目,检验是否真的理解