测试分层与回归保护
写测试的目的不是让覆盖率数字好看,而是让团队敢改代码。但很多项目的测试处于这样的状态:
- CI 一直是绿的,上线后还是频繁出 bug;
- 有几个测试“时好时坏”,失败了大家就点重试,慢慢就没人相信测试了;
- 几百个端到端测试要跑四十分钟,一改界面就挂一片;
- 同一个 bug 修了三次,每次都又回来了。
这篇讲清楚测试该怎么分层、测什么,以及怎么让测试真正值得信任。
读完这篇你会
- 说清单元、集成、端到端测试各自的分工
- 按风险挑选最值得写的测试用例
- 处理不稳定测试,用回归测试防止 bug 复发
1. 原理:测试像体检套餐
体检有不同的项目:量血压、验血很便宜、很快,可以经常做;全身核磁共振很贵、很慢,只在必要时做。好的体检方案是大量便宜的基础检查 + 少量昂贵的全面检查。
测试也一样:
| 层次 | 测什么 | 速度和成本 | 发现问题后能否快速定位 |
|---|---|---|---|
| 单元测试 | 一个函数、一条业务规则 | 毫秒级,非常便宜 | 能,直接指向那个函数 |
| 集成测试 | 几个模块配合,或者和数据库、接口的交互 | 秒级 | 比较容易 |
| 端到端测试 | 从浏览器操作到后端的完整用户流程 | 慢,维护成本高 | 难,要逐层排查 |
所以,业务规则尽量用单元测试覆盖(金额怎么算、权限怎么判断、状态怎么流转),模块之间的配合用集成测试确认,端到端测试只覆盖少数关键流程(注册、下单、支付)。
这也要求代码本身好测:业务规则写成不依赖数据库、不依赖界面的纯函数,测试起来就又快又简单。
2. 先测“错了会出事”的地方
时间有限时,按风险挑选用例:
- 算错会造成损失的规则:金额、优惠、库存、积分;
- 出错会造成安全问题的规则:权限、数据归属;
- 曾经出过 bug 的地方:每个修复过的 bug 都应该有一个测试守着;
- 边界输入:空值、0、负数、超长字符串、重复提交、过期数据;
- 失败分支:网络超时、依赖异常、无权限、资源不存在。
测试名称描述业务行为,而不是内部方法:
js
it('普通成员不能编辑他人的文章', () => {
expect(canUpdateArticle(memberA, articleOfMemberB)).toBe(false)
})测试失败时,报告里直接显示“普通成员不能编辑他人的文章”失败了,任何人都知道是什么行为被破坏了。
3. 坑一:只测正常流程
现象:CI 一直是绿的,团队对测试很有信心。上线后却发现:库存不足时没有拒绝下单;未登录用户能直接调用接口;金额为负数时算出了奇怪的结果。
js
// 只测了“一切正常”的情况
it('创建订单成功', () => {
expect(createOrder(validUser, { amount: 100 }).status).toBe('created')
})原因:正常流程开发者自己都会手动点一遍,最不容易出问题;真正出事的往往是边界和失败。只测正常流程,等于只守住了最安全的那扇门。
修复:把每一个失败分支都当作一个独立的用例:
js
it('库存不足时拒绝创建订单', () => {
expect(() => createOrder(validUser, { productId: 'sold-out', amount: 100 }))
.toThrow('INSUFFICIENT_STOCK')
})
it('未登录时不能创建订单', () => {
expect(() => createOrder(null, { amount: 100 })).toThrow('UNAUTHORIZED')
})
it('金额必须为正数', () => {
expect(() => createOrder(validUser, { amount: -1 })).toThrow('INVALID_AMOUNT')
})写完正常流程的测试后,问自己:“如果这个输入是空的、非法的、重复的、恶意的,代码会怎样?”答不上来的地方,就是该补测试的地方。
4. 坑二:测试时好时坏,大家习惯了点重试
现象:某个测试大约十次里失败一次,每次失败大家就重新运行一下 CI。慢慢地,“测试失败”不再引起任何人的注意,真正的问题也被当成偶发失败忽略了。
原因:测试依赖了不确定的东西:
| 不确定因素 | 处理方法 |
|---|---|
| 当前时间(“3 天后过期”“今天是周末”) | 注入时钟或使用假定时器,固定时间 |
| 随机数 | 固定随机种子,或把随机数生成器作为参数传入 |
| 真实的外部接口 | 使用按契约生成的 mock,或专门的测试环境 |
| 共享的数据库数据 | 每个测试准备自己的数据,结束后清理(或在事务中运行并回滚) |
| 测试之间的执行顺序 | 测试之间不共享状态,每个测试都能单独运行 |
用固定时长等待异步结果(sleep(1000)) | 等待明确的条件,而不是等待固定时间 |
修复:不稳定的测试要立即处理:先把它隔离(暂时跳过并登记),再找到并消除不确定的来源。不要依赖自动重试掩盖问题。
5. 坑三:端到端测试又慢又容易挂
现象:端到端测试越写越多,CI 要跑四十分钟;界面上改个按钮文字,二十个测试一起失败。
原因:把本该在单元测试里验证的规则(比如各种金额计算的组合),都放到了端到端测试里,通过浏览器点击来验证。
修复:
- 规则的各种组合用单元测试覆盖,速度快、定位准;
- 端到端测试只保留关键流程的“主路径”,确认页面、接口、权限能真正串起来;
- 端到端测试定位元素时,使用按钮文字、表单标签或专门的
data-testid,不要依赖样式类名和 DOM 层级。
6. 坑四:修好的 bug 又回来了
现象:一个“优惠券叠加后金额为负”的 bug,三个月内出现了三次,每次都是后来的改动又把它带了回来。
修复:修 bug 时先写测试,再改代码:
- 写一个能复现这个 bug 的测试,确认它现在是失败的;
- 修改代码,让这个测试通过;
- 这个测试永久留在测试集里。以后任何改动再引入同样的问题,它都会立刻失败。
js
// 回归测试:对应线上问题“优惠券叠加后金额为负”
it('多张优惠券叠加后,应付金额最低为 0', () => {
expect(calcPayable({ total: 50, coupons: [30, 40] })).toBe(0)
})这样,测试集会随着每一次线上问题持续变强。
7. 坑五:覆盖率达标了,测试却没什么用
现象:团队要求覆盖率达到 80%,于是出现了很多只调用函数、不检查结果的测试。
原因:覆盖率只能说明代码被执行过,不能说明结果被检查过。
修复:把覆盖率当作一张“地图”,用来发现完全没被测到的重要代码,而不是一个必须达到的数字。对关键模块,可以偶尔故意改错一处代码(比如把 >= 改成 >),看测试能不能发现。发现不了,说明测试只是“路过”了这段代码。
8. 测试通过不等于可以放心上线
自动化测试覆盖不到配置、权限设置、数据迁移和真实环境的差异。上线前仍需要检查这些,上线后观察错误率和关键业务指标。线上出现的每一个问题,都应该转化为一条新的测试或一个新的约束。
排查思路
- 测试不稳定:在本地把这个测试连续运行几十次,看失败的频率;打乱测试顺序运行,看是否和顺序有关;检查测试里是否用到了当前时间、随机数、真实网络或共享数据。
- 测试失败但看不懂:先看测试名称描述的是什么行为,再看期望值和实际值的差异。测试名称写得好,往往不用看代码就知道问题在哪。
- CI 失败但本地通过:对比时区、语言环境、Node 版本和环境变量。
- 测试太慢:找出最慢的一批测试,看它们是否在用端到端测试验证本该由单元测试覆盖的规则。
检查清单
- [ ] 业务规则写成纯函数,用单元测试覆盖
- [ ] 端到端测试只覆盖少数关键流程
- [ ] 失败分支和边界输入都有对应的测试
- [ ] 测试不依赖真实时间、随机数、外部服务和执行顺序
- [ ] 不稳定的测试会被立即隔离和修复,而不是反复重试
- [ ] 每个修复的 bug 都有一个回归测试
- [ ] 覆盖率用来发现盲区,而不是作为考核数字