测试金字塔给的经典配比是 70/20/10,但直接套到前端项目上会失真。理解失真在哪里,比记住一个数字重要。
金字塔在前端的现实形态
经典金字塔的两个前提在前端都不完全成立。
E2E 比后端的集成测试贵得多。后端集成测试通常在进程内跑,而前端 E2E 要启动真实浏览器、加载完整应用、等待网络与渲染。单个用例的耗时量级从毫秒级跳到秒级,相差三个数量级。慢不只是等待时间的问题,它直接决定了反馈周期能否塞进开发流程。
纯逻辑单测覆盖不到前端的主要风险区。前端的 bug 大量出在组件之间的契约上:props 传错了类型、事件回调没接上、context 更新没触发重渲染、样式在特定组合下崩了。这些在隔离的单测里全都看不见,因为单测恰好把这些连接处 mock 掉了。
于是实际的形状更接近菱形:中间那层——组件渲染与交互测试——要比经典金字塔厚得多。它既能覆盖”连接处”的 bug,又不需要真的启动浏览器。
三种测试的职责边界
| 单测 | 组件测试 | E2E | |
|---|---|---|---|
| 对象 | 纯函数、reducer、工具方法 | 组件及其直接子树 | 跨页面的完整用户路径 |
| 是否 mock 网络 | 不涉及 | mock | 一般不 mock |
| 是否 mock 子组件 | 不涉及 | 尽量不 mock | 不 mock |
| 速度量级 | 毫秒 | 十到百毫秒 | 秒 |
| 回答的问题 | 这段逻辑算得对吗 | 渲染和交互对吗 | 整条链路能跑通吗 |
判断一条断言该放哪层的原则:如果需要启动浏览器才能验证,就别放单测;如果跟 UI 无关,就别放 E2E。
一个具体的例子。”折扣计算”是纯逻辑,放单测;”购物车里改数量后总价跟着变”是组件交互,放组件测试;”从加购到支付成功”是 E2E。三层各管一段,不要重复覆盖同一件事。
脆弱 E2E 的三个根源
选择器绑定了实现细节
// 脆弱:依赖 DOM 结构和 class 名
await page.click('div.container > div:nth-child(3) > button.btn-primary');
// 稳健:从用户可见的语义出发
await page.getByRole('button', { name: '提交订单' }).click();
后者的好处不止是稳。用 getByRole 意味着这个按钮在无障碍树里有正确的角色和名称,测试顺带验证了可访问性。重构样式、换组件库、调整 DOM 层级都不会让测试失败。
data-testid 是退路而非常态——只在元素没有可访问的语义标识时使用(比如纯装饰性的拖拽把手)。
等待策略靠固定时长
// 脆弱:慢机器上不够,快机器上浪费
await page.waitForTimeout(2000);
// 稳健:等待可观测的状态变化,自带重试
await expect(page.getByText('下单成功')).toBeVisible();
断言库的重试机制会持续轮询直到超时,既不浪费时间也不会因为偶发延迟失败。涉及异步数据加载的场景,等待确定的网络请求完成比等待界面元素更精确。
还有一个常被忽略的干扰源:入场动画和骨架屏。在测试环境禁用 CSS 过渡和动画,能消除一整类”元素可见但还没动画到位”的 flaky。
测试之间共享状态
数据库残留记录、未清理的 localStorage、并发执行时的账号冲突,都会让单独跑通过的用例在批量跑时失败。
对策是让每个用例自带完整的准备和清理:
test.beforeEach(async ({ page, request }) => {
// 用 API 直接构造前置状态,而不是靠 UI 点击一步步走到目标页面
await request.post('/api/test/setup', { data: { cart: [{ sku: 'A1', qty: 2 }] } });
});
test.afterEach(async ({ request }) => {
await request.delete('/api/test/cleanup');
});
用 API 构造前置状态有两个收益:快,且不受 UI 改动影响。同时必须保证用例之间没有顺序依赖——任何一条用例单独运行都应该通过。并行执行时,给每个 worker 分配独立账号和数据命名空间。
flaky 用例的处理要克制。自动重试能把 5% 的失败率压到 0.25%,但没解决任何问题,只是让信号变模糊。更合理的流程是:自动检测出 flaky(同一 commit 重复运行结果不一致)→ 移入隔离队列不阻塞主干 → 限期修复 → 修不好就降级成组件测试或删除。
mocking 的边界
mock 网络层是划算的。网络是最慢、最不可控的一环,mock 掉能换来确定性和速度。风险在于 mock 与真实后端漂移——契约改了,mock 没改,测试全绿线上全红。缓解手段:mock 数据从接口 schema 生成;定期在真实环境跑一遍冒烟;把契约测试单独放进定时流水线。
mock 子组件要谨慎。把子组件 mock 掉之后,测试断言的其实是”父组件给子组件传了什么”,而不是”用户看到了什么”。结果是测试对 props 接口变化极其敏感,对真实回归却不敏感——改了子组件的渲染逻辑,测试照样通过。
例外情况是合理的:子组件极重(地图、富文本编辑器、iframe)、有难以隔离的全局副作用、或者来自第三方无法修改。
mock 时间值得做,用测试框架提供的 fake timers,而不是全局替换 Date.now。
不该 mock 的是被测单元自身的逻辑,以及那些简单到没有不确定性的工具函数。
覆盖率数字不能代表有效性
覆盖率衡量的是”哪些代码被执行过”,不是”哪些行为被验证过”。一个没有任何断言的测试,只要调用了函数,就能把覆盖率刷上去。
几个具体原因:
- 执行到不等于验证对。函数被调用、分支被走到,但断言可能只检查了一个无关紧要的字段,甚至只检查了”没抛异常”。
- 边界不在覆盖率里。空列表、超长输入、并发竞态、跨时区、权限边界,这些往往只占几行代码,覆盖率上看不出差别。
- 稀释效应。项目后期为了冲数字补的测试,质量通常低于早期写的,但它们同样计入分母。
更有参考价值的信号有两个。一是变异测试:主动往源码里注入错误(改个比较符、换个边界值),看测试能不能发现。发现率比行覆盖率更接近”有效性”。二是缺陷逃逸率:线上 bug 里有多少是本可以被现有测试层次拦截的,这直接反映了策略的缺口在哪层。
覆盖率更实用的用法是找盲区:看哪些文件是 0%,看 PR 的增量覆盖率,而不是盯着全量总数。全量覆盖率 80% 的项目,核心链路可能一行都没测。
一套可落地的配比与 CI 分层
按用例数量而不是代码行数来分配:
- 纯逻辑单测 60%–70%。工具函数、状态转换、数据处理、格式化。
- 组件测试 25%–35%。渲染输出、用户交互、条件分支界面。这是前端的主力层。
- E2E 5%–10%。只覆盖关键路径。
关键路径的判定标准是:出故障会直接造成收入损失,或阻断用户的核心目标。典型的有注册登录、加购下单支付、核心内容发布与编辑、账号安全相关操作。一个中等规模的应用通常也就五到二十条,超过这个数说明把不该放 E2E 的断言放进去了。
CI 分层的核心是让反馈周期与验证强度解耦:
| 阶段 | 执行内容 | 目标耗时 |
|---|---|---|
| 本地 pre-commit / pre-push | lint、类型检查、改动文件的单测 | 秒级 |
| PR 流水线 | 全量单测 + 组件测试 + 构建 | 数分钟 |
| 合入主干后 | 全量 E2E、视觉回归、性能预算 | 十分钟级 |
| 定时任务 | 跨浏览器矩阵、契约测试、变异测试抽样 | 不限 |
PR 阶段不放全量 E2E,否则每次提交都要等十几分钟,反馈失去意义。可以放一个关键路径的 E2E 子集作为门禁,全量留给主干和定时任务。
这套配比不是固定的。组件逻辑占比高的项目,中间层还要继续加厚;表单和流程密集的应用,E2E 的比例可以往上调。判断依据是缺陷逃逸率的分布——哪层漏得多,就往哪层补。
评论(0)
还没有评论,来抢沙发。
请 登录 后参与评论。