Skip to content

测试分层与回归保护 ​

写测试的目的不是让覆盖率数字好看,而是让团队敢改代码。但很多项目的测试处于这样的状态:

  • 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 时先写测试,再改代码:

  1. 写一个能复现这个 bug 的测试,确认它现在是失败的;
  2. 修改代码,让这个测试通过;
  3. 这个测试永久留在测试集里。以后任何改动再引入同样的问题,它都会立刻失败。
js
// 回归测试:对应线上问题“优惠券叠加后金额为负”
it('多张优惠券叠加后,应付金额最低为 0', () => {
  expect(calcPayable({ total: 50, coupons: [30, 40] })).toBe(0)
})

这样,测试集会随着每一次线上问题持续变强。

7. 坑五:覆盖率达标了,测试却没什么用 ​

现象:团队要求覆盖率达到 80%,于是出现了很多只调用函数、不检查结果的测试。

原因:覆盖率只能说明代码被执行过,不能说明结果被检查过。

修复:把覆盖率当作一张“地图”,用来发现完全没被测到的重要代码,而不是一个必须达到的数字。对关键模块,可以偶尔故意改错一处代码(比如把 >= 改成 >),看测试能不能发现。发现不了,说明测试只是“路过”了这段代码。

8. 测试通过不等于可以放心上线 ​

自动化测试覆盖不到配置、权限设置、数据迁移和真实环境的差异。上线前仍需要检查这些,上线后观察错误率和关键业务指标。线上出现的每一个问题,都应该转化为一条新的测试或一个新的约束。

排查思路 ​

  1. 测试不稳定:在本地把这个测试连续运行几十次,看失败的频率;打乱测试顺序运行,看是否和顺序有关;检查测试里是否用到了当前时间、随机数、真实网络或共享数据。
  2. 测试失败但看不懂:先看测试名称描述的是什么行为,再看期望值和实际值的差异。测试名称写得好,往往不用看代码就知道问题在哪。
  3. CI 失败但本地通过:对比时区、语言环境、Node 版本和环境变量。
  4. 测试太慢:找出最慢的一批测试,看它们是否在用端到端测试验证本该由单元测试覆盖的规则。

检查清单 ​

  • [ ] 业务规则写成纯函数,用单元测试覆盖
  • [ ] 端到端测试只覆盖少数关键流程
  • [ ] 失败分支和边界输入都有对应的测试
  • [ ] 测试不依赖真实时间、随机数、外部服务和执行顺序
  • [ ] 不稳定的测试会被立即隔离和修复,而不是反复重试
  • [ ] 每个修复的 bug 都有一个回归测试
  • [ ] 覆盖率用来发现盲区,而不是作为考核数字

延伸阅读 ​

为复用而记录,为理解而整理。