前端测试体系:单测、组件测试与端到端的分层实践
本文目录
前端的测试比后端更难做好:同一个功能涉及渲染、交互、网络、时序,把它们全塞进一种测试里,要么慢得没人跑,要么脆得没人敢动。分层的答案是:纯逻辑给单测,用户行为给组件测试,关键路径给端到端,其余的交给类型系统和 review。这篇文章按这套分层展开:Vitest 单测、Testing Library 组件测试、Playwright 端到端,以及两个决定成败的非工具问题——mock 的边界和覆盖率的误用。
分层模型:金字塔在前端的实际形态
经典测试金字塔(大量单测、少量集成、极少 E2E)来自后端。前端按"运行环境接近用户"的程度分层,但比例要做两个修正:
| 层 | 工具 | 速度 | 反馈信息 | 占比 |
|---|---|---|---|---|
| 单元:纯函数/组合函数 | Vitest | 毫秒级 | 逻辑错误精确定位 | 多 |
| 组件:DOM + 交互 | Testing Library + jsdom | 十毫秒级 | 行为与可访问性缺陷 | 前端最多的一层 |
| E2E:真实浏览器 | Playwright | 秒级 | 整链路是否可用 | 少而关键 |
修正一:组件测试在前端是占比最高的一层。前端的核心复杂度在"数据如何变成界面、用户如何操作界面",这层用金字塔原教旨的"纯单测"覆盖不到。
修正二:E2E 只保关键路径。登录、下单、支付这类失败代价最高的链路配 E2E;每个分支都做 E2E 是用最贵、最慢、最脆的手段覆盖最少的场景。
分布示意(自上而下越接近用户、数量越少):
Vitest 单元测试:纯函数与自定义 Hook
Vitest 与 Vite 共享配置和转换管道,expect、mock、快照 API 与 Jest 高度兼容。单测层的主对象是与 DOM 无关的纯逻辑:格式化、校验、 reducer、日期计算、组合函数。
// money.ts
export function formatCents(cents: number): string {
if (!Number.isInteger(cents)) throw new RangeError("cents 必须是整数");
const sign = cents < 0 ? "-" : "";
const abs = Math.abs(cents);
return `${sign}${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, "0")}`;
}
// money.test.ts
import { describe, it, expect } from "vitest";
import { formatCents } from "./money";
describe("formatCents", () => {
it("格式化常规金额", () => {
expect(formatCents(12345)).toBe("123.45");
});
it("补齐分位", () => {
expect(formatCents(5)).toBe("0.05");
});
it("负数带符号", () => {
expect(formatCents(-100)).toBe("-1.00");
});
it("非法输入抛错", () => {
expect(() => formatCents(1.5)).toThrow(RangeError);
});
});
纯函数测试的关键是用例从边界条件出发:零、负数、空输入、非法类型,而不是罗列正常值——正常路径一条用例就够。
带状态的逻辑(React 自定义 Hook、Vue 组合函数)用 renderHook 测。React 侧:
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "./useCounter";
it("每次递增且可重置", () => {
const { result } = renderHook(() => useCounter(5));
act(() => result.current.inc());
act(() => result.current.inc());
expect(result.current.count).toBe(7);
act(() => result.current.reset());
expect(result.current.count).toBe(5);
});
Vue 组合函数可以直接当成普通函数测——ref 的更新是同步可读的,这比 React 的测试写法直接得多:
import { useCounter } from "./useCounter";
it("递增逻辑", () => {
const { count, inc } = useCounter(5);
expect(count.value).toBe(5);
inc();
expect(count.value).toBe(6);
});
组件测试:Testing Library 的查询优先级
Testing Library 的核心哲学:测试用户能感知到的东西。用户看到文字和角色、通过标签和按钮操作,看不到组件内部 state 和 class 名。查询 API 的优先级直接编码了这个哲学:
| 优先级 | 查询 | 说明 |
|---|---|---|
| 1 | getByRole / getAllByRole | 按可访问性角色查(button、textbox、listitem),语义最强 |
| 2 | getByLabelText | 表单字段与其 label 的关联 |
| 3 | getByPlaceholderText | 辅助,不宜作为主定位 |
| 4 | getByText | 非交互内容 |
| 5 | getByTestId | 兜底:动态内容、纯展示的列表项等 |
| 谨慎 | getByClassName / DOM 结构断言 | 组件内部实现细节,重构即碎 |
一个完整的组件测试样例——模拟用户操作而不是直接触发内部状态:
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TodoList } from "./TodoList";
it("添加任务后出现在列表中", async () => {
const user = userEvent.setup();
render(<TodoList />);
const input = screen.getByRole("textbox", { name: /新增任务/ });
await user.type(input, "整理周报");
await user.click(screen.getByRole("button", { name: "添加" }));
expect(screen.getByRole("listitem", { name: /整理周报/ })).toBeVisible();
});
it("空输入不产生任务", async () => {
const user = userEvent.setup();
render(<TodoList />);
await user.click(screen.getByRole("button", { name: "添加" }));
expect(screen.queryByRole("listitem")).toBeNull(); // queryBy 用于断言不存在
});
可执行的规则:
- 用
getByRole+ accessible name 做主要定位。写不出 role 查询通常说明组件可访问性本身有问题——测试反过来暴露产品缺陷,这是这套 API 的隐藏价值。 - 断言对与
toBeVisible/toBeInTheDocument,不 assert state 与 class。 - 异步交互统一走
userEvent(模拟真实键盘/鼠标事件序列),不用fireEvent(直接派发合成事件,绕过浏览器行为)。
E2E:Playwright 的选型理由
E2E 工具的选型要点是稳定性和排查能力,不是写起来快:
| 维度 | Playwright | 传统方案常见短板 |
|---|---|---|
| 等待模型 | 全链路 auto-wait(等元素可见、可操作、网络稳定) | 手写 sleep/显式 wait,时快时慢 |
| 选择器 | getByRole/getByText 优先,内置最佳实践提示 | CSS/XPath 依赖实现细节 |
| 跨浏览器 | Chromium / Firefox / WebKit 单一 API | 需要多个工具拼装 |
| 失败排查 | Trace Viewer(截图、DOM 快照、网络、console 回放) | 只有一张失败截图 |
| 并行 | Worker 进程并行 + 分片 | 串行慢或需自建 |
选型理由的内核是第一条:E2E 的失败多数是"等待不当"造成的假失败,工具层面的 auto-wait 直接消灭了这类问题。
稳定性技巧一:等待策略
import { test, expect } from "@playwright/test";
test("搜索并打开结果", async ({ page }) => {
await page.goto("/search");
// 正确:web-first 断言自动重试直到条件满足或超时
await expect(page.getByRole("heading", { name: "搜索结果" })).toBeVisible();
await page.getByRole("textbox", { name: "搜索" }).fill("playwright");
await page.getByRole("button", { name: "搜索" }).click();
// 正确:断言终态,而不是断言中间 loading 出现过
await expect(page.getByRole("link", { name: /官方文档/ })).toBeVisible();
});
等待三条规则:
- 用
await expect(locator).toBeVisible()等"事情完成",别等"事情开始"(等 loading 图标出现是最典型的反模式)。 - 禁止
page.waitForTimeout(1000)——固定睡眠既慢又不可靠,出现一次就该被 review 打回。 - 涉及后端的用例,测之前准备好数据(API 种子),测完清理,不在 UI 里先做一套"准备数据"的操作再开始测。
稳定性技巧二:选择器策略
优先级与组件测试同构:role/label → text → data-testid;禁 CSS 选择器与 XPath。另一个易碎来源是"同一文案匹配多个元素",用 .first()、.nth() 之前先确认无法用 accessible name 唯一化,让选择器精确而不是事后加序号。
mock 的边界:不要 mock 你要测的东西
mock 是稳定性工具,用错方向会静默制造假通过。分界线一句话:mock 的应该是"变化慢的依赖",要测的必须是"真实的东西"。
| 依赖 | 策略 | 理由 |
|---|---|---|
| 后端 HTTP 接口 | MSW(Mock Service Worker)在请求层拦截 | 组件逻辑、fetch、错误分支全部真实 |
| 纯粹的外部服务(短信、支付) | 固定 stub | 无法在测试里真调 |
| 浏览器 API(剪贴板、通知) | 局部 stub | jsdom 未实现 |
| 要测的模块本身 | 禁止 | mock 了被测对象等于什么都没测 |
| 工具函数(被测代码依赖的) | 谨慎 | 能用真的就用真的,mock 后用例随实现一起腐化 |
MSW 的价值在于 mock 发生在网络层而不是模块层,组件和请求代码都按真实路径运行:
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
const server = setupServer(
http.get("/api/items", () => {
return HttpResponse.json([{ id: "1", name: "笔记本" }]);
}),
http.post("/api/items", () => {
return new HttpResponse(null, { status: 500 }); // 测错误分支:真实响应码
}),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
契约风险要显式处理:mock 的响应形状可能和真实接口漂移。两个缓解手段——mock 数据从后端 OpenAPI schema 生成;或在 CI 里加少量不打 mock 的"真实后端冒烟用例"兜底。
覆盖率:数字的意义与误用
覆盖率度量的是"代码被测试执行了",不是"代码被测试验证了"。一个 expect 都没有的测试也能把行覆盖率刷满。
| 覆盖率用法 | 结论 |
|---|---|
| 阈值 100%,为凑数写断言极弱的用例 | 有害:制造虚假信心,拖累维护 |
| 核心支付/鉴权模块要求 80%+ | 合理:高风险代码强制覆盖 |
| 看趋势而非绝对值(本次 PR 覆盖率明显下滑则 review 警惕) | 合理:把数字当信号 |
| 用分支覆盖率补充行覆盖率 | 合理:只看行会漏掉 else 分支 |
| 覆盖率高的模块 = 测得好 | 误用:数字与质量无必然关系 |
| E2E 覆盖率统计 | 误用:UI 走一遍不代表逻辑都被断言 |
另一个值得了解的工具是变异测试(如 Stryker):把代码故意改坏,看多少修改能被测试抓住——直接度量"测试是否真的在验证",能击穿一切凑数用例。它运行成本高,适合抽检关键模块而不是全量跑。
把资源投给"失败代价 × 发生概率"最高的代码:支付流程、权限判断、数据处理。覆盖率报告真正的用法是找"完全没被测过的模块",而不是追求总数。
注意事项与边界
- jsdom 不是真实浏览器:布局(getBoundingClientRect 恒为零)、canvas、真实滚动都不可测,这类行为归 E2E 层或组件测试里显式标 xfail。
- 测试之间必须互相独立:共享模块级状态(store、MSW handler、定时器)的用例顺序一变就挂;
beforeEach重置、vi.useFakeTimers()控制 setTimeout 才是稳定来源。 - 快照测试(
toMatchSnapshot)只适合稳定的结构化数据(配置、编译产物),DOM 快照会在无害改动下天天红,最终被整段--update掉,等于没有断言。 - 组件测试不要 mock 全部子组件:mock 了子组件,父子交互就全在测 mock 而不是测代码。
- E2E 数据准备走 API 而不是 UI(重复登录造数据会把用例时长翻倍并把登录页的 bug 放大到所有用例)。
- CI 里测试必须分片并行,全量 E2E 串行超过 10 分钟的套件会很快被"临时 skip"架空。
- 覆盖率对"未测"是证明,对"测好"不是——用数字找缺口,用 review 和变异测试验质量。
总结
分层的本质是让每类验证发生在它能覆盖目标、成本又最低的层:纯逻辑给 Vitest 毫秒级反馈,组件行为给 Testing Library 的用户视角查询,关键链路给 Playwright 的 web-first 断言与 trace。两个纪律决定这套体系是资产还是负担:mock 只拦外部依赖、绝不 mock 被测对象;覆盖率用于找缺口而不是刷数字。组件层占比最高是前端和后端金字塔最大的差异,认清这一点,测试才不会在"全单测"和"全 E2E"两个极端间摇摆。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。