知

模块三 · 路由、测试与服务端

第 12 / 18 节19 分钟阅读型

测试:Vitest、RTL 与 Playwright 的分工

测试的价值不在覆盖率,而在"改错时能不能拦住"。这一节讲清 RTL 的查询哲学、Hook 测试与 Mock 边界,以及让 E2E 不摇的四个前提。

读完这一节,你能做到

  1. 按测试金字塔分配三类测试,不为小函数写 E2E。
  2. 用 RTL 写出"像用户一样"的测试,而不是绑定实现细节。
  3. 用 renderHook 测自定义 Hook,用 MSW 管住网络。
  4. 让 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% 全测渲染的项更可靠。

八、常见坑

  1. 用 container.querySelector 选元素,改结构就红。
  2. fireEvent.change 代替 userEvent.type,测不出受控组件的输入问题。
  3. 异步断言用固定 setTimeout,CI 上随机失败。
  4. mock 掉整个请求模块,接口改了测试仍绿。
  5. 每个 E2E 都重新登录,套件慢到没人跑。
  6. 用例之间共享数据库,顺序一变就挂。
  7. 只测 happy path,错误分支与空态零覆盖。
  8. 用快照测试整页 DOM,任何改动都要更新快照,最后没人看 diff。

九、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 给一个 reducer 写三条用例:加载、成功、失败。
  2. 给一个搜索组件写组件测试:输入、回车、清空,全部用 getByRole。
  3. 用 renderHook 测一个带异步请求的 Hook,用 MSW 返回固定数据。
  4. 写一条 Playwright 用例覆盖"登录 → 列表 → 打开详情",要求不出现 sleep。

② 让 AI 写同一题

把第 2 题(搜索组件)交给 AI,补一句 用 getByRole 查询,不许 mock 组件内部的自定义 Hook。

③ 对比要盯的点

  • 它有没有 vi.mock 掉自定义 Hook?那测的就不是组件行为,而是你的 mock。
  • 查询用的是 getByRole,还是 container.querySelector?后者把测试绑在实现上。
  • 异步它是 findBy / waitFor,还是 setTimeout 硬等?后者在 CI 上一定抖。

④ 一句话取舍

它写的这条测试,在你“只改实现不改行为”时会不会红?红了说明它测错了什么?

十、自测

  1. 三层测试各自适合测什么?
  2. RTL 的查询优先级为什么从 role 开始?
  3. userEvent 与 fireEvent 的区别是什么?
  4. 为什么推荐 MSW 而不是 mock 请求模块?
  5. 覆盖率为什么不能当 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 实践:

  1. 单测与类型检查分开跑,失败信息更清晰。
  2. 测试只跑受影响范围:用 vitest --changed 或按目录分批,大仓不必全量。
  3. E2E 用预览环境,不要在开发服务器上跑;开发服务器的 HMR 会让断言不稳定。

最后一个常被忽略的点:测试也要能本地一键复现。如果只能在 CI 上跑,开发时就不会有人跑,最后变成“合并前才红”。

测试的维护成本与断言方式直接相关:断言用户可见行为,重构时几乎不用改;断言内部结构,每次实现调整都要跟着改,最后被删掉。写测试前先问一句“这行断言在重构后还成立吗”,能省掉大量后续维护。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解