单元测试到底该测什么?我重写三次才想明白:覆盖率 87% 依然拦不住线上退款事故

🔑 关键词:单元测试,测试覆盖率,变异测试,mock 使用边界,测试执行速度优化

📖 摘要:从一次真实的退款金额事故讲起,拆解单元测试里最容易被搞错的四件事:覆盖率指标怎么被伪造、mock 数量的合理边界在哪、单测与集成测试的分界线怎么划、CI 跑得慢到底该优化哪一步。含 Vitest / pytest / JUnit 5 的具体配置参数、变异测试的实测数据对比,以及一套「删测试比写测试更难」的落地判断标准。

单元测试到底该测什么?我重写三次才想明白

图片

先说个具体的事。2021 年我在一家做 SaaS 的公司,后端订单模块的单元测试行覆盖率是 87%,CI 上挂着 SonarQube 的质量门禁,低于 80% 直接挡住合并。看起来挺健康。然后那年大促前一周出事了:退款金额算错,用户退 100 块实际打过去 1000 块。

问题出在 RefundCalculator 的折扣分摊逻辑,用的 BigDecimal.divide() 没指定 scale,碰到除不尽的情况直接抛 ArithmeticException。这个异常被上层 catch 了,走了一条兜底分支,兜底分支里的金额是用 double 算的,然后 assertEquals(100.0, result) 这种断言也测不出来。

我们当然测过退款,测了十几个 case。但那些 case 全是 @SpringBootTest 起整个容器,mock 掉支付网关、库存、MQ,然后断言一个 double。divide() 的 scale 参数压根没进过测试的视野。那之后我把整个模块的单测重写了一遍,覆盖率从 87% 掉到 72%。领导问我为什么。这个问题我后来花了两年才答明白,下面是我踩过的坑。

覆盖率是最容易被伪造的指标之一

行覆盖率(Line Coverage)的本质是「这行代码有没有被执行过」,它和「这行代码是否被验证过」是两件事。assertNotNull(result)assertTrue(list.size() > 0)、甚至干脆不写断言只调用一遍,都能把行覆盖率刷上去。我在第三个团队见过有人专门写了个 CoverageBoosterTest,遍历所有 DTO 的 getter/setter 和 toString(),一口气把覆盖率从 61% 拉到 79%。

几个容易混的指标得掰开说:JaCoCo 报的是 instruction / branch / line / complexity 四种,Istanbul(nyc)报的是 statements / branches / functions / lines,pytest-cov 底层也是 coverage.py,它默认给的是语句覆盖,分支覆盖要加 --cov-branch。这几个数字在同一份代码上能差出 20 个百分点,你拿 A 项目的行覆盖率去比 B 项目的分支覆盖率,等于没比。

图片

真正有区分度的是分支覆盖,但也不是全都一样。if (a && b) 这种短路表达式的分支,coverage.py 和 JaCoCo 的计数方式就不同。所以我现在拿到一个「覆盖率 85%」的说法,第一反应是问三件事:哪个工具报的、哪个口径、有没有排除 **/generated/****/dto/**

我自己现在只看两条:一是覆盖率不许下降(PR 里 diff 出来的新代码必须 100% 有断言),二是变异得分。第二条下面细说。

变异测试才是那个敢说真话的指标

覆盖率告诉你「代码跑到了」,变异测试告诉你「断言真的能抓到错误吗」。原理很朴素:工具自动把 > 改成 >=、把 return a 改成 return 0、把 + 改成 -,每改一次就跑一遍你的测试,如果测试全绿,说明这个变异「存活」了,你的断言没发现它。

工具选型上,Java 用 PIT(pitest-maven 插件 1.15.x),JS/TS 用 Stryker Mutator(npx stryker run,配置文件 stryker.config.json),Python 用 mutmut 或者 cosmic-ray。这几个我都跑过。

图片

回到那个订单模块。我接上 PIT 之后跑了 4 分 12 秒,行覆盖 87%,而变异得分只有 31%。什么意思?就是我把代码里三分之二的逻辑改坏,测试照样全绿。那 87% 里塞满了「调用了但没验证」的空壳测试。修复之后覆盖率 72%,变异得分 68%。数字看着变差了,但心里踏实多了。

实操建议:不要全量跑变异测试,太慢,PIT 在中等项目上动辄十几分钟。我的做法是只在 PR 的 diff 上跑,pitest-maven 配合 -Dfeatures=+GIT 可以只分析改动的类,Stryker 用 --incremental 缓存上一轮结果。把变异得分当趋势看,别当门禁,低于 50% 就该有人去看看是不是又有人在刷覆盖率了。

mock 的数量应该和 IO 边界成正比,而不是和类数量成正比

这是我观点最强的一条,可能有人不同意。

Martin Fowler 2004 年那篇《Mocks Aren't Stubs》里把测试分成 classicist(经典学派,用真实对象)和 mockist(伦敦学派,万物皆 mock)。国内大部分团队其实没选过,是跟着教程走的,教程里 @Mock 用得飞起,于是大家就默认「被测类依赖的一切都要 mock」。结果就是一个 200 行的 Service 类,测试文件里躺着 6 个 @Mock、3 个 when(...).thenReturn(...)、还有两个 verify(...)

这种测试的形状是这样的:你改一下 UserService 内部调用 EmailSender 的顺序,测试红了;你把两个内部方法合并成一个,测试红了;你只是重命名了一个私有方法,测试红了。它不是在设计文档,它是在给实现拍快照。

图片

我的判断标准很简单:只对跨进程边界的东西打桩,也就是网络、数据库、文件系统、时钟、随机数。 同一个进程里的普通对象,用真的。EmailSender 接口的实现是发 HTTP,那就 mock 这个接口;PriceCalculator 是纯内存计算,就算它依赖 5 个其他对象,也一律用真的构造出来。

这条规则会让你的 fixture 变长一点,但换来的是:重构内部实现的时候测试不红。我上个项目按这个原则把 180 个测试砍到 104 个,重构期间测试误报从每周十几次降到两三次。

有一个例外是时间。Instant.now()new Date()System.currentTimeMillis() 这类必须处理,否则测试必然 flaky。Java 里注入 java.time.Clock,测试传 Clock.fixed(Instant.parse("2024-03-01T00:00:00Z"), ZoneOffset.UTC);JS 里用 Vitest 的 vi.useFakeTimers() + vi.setSystemTime(new Date('2024-03-01'));Python 里用 freezegun@freeze_time("2024-03-01")。这三个我都用过,比到处 mock 一个 TimeProvider 干净。

什么叫「一个单元」?给个能用的判断标准

教科书说单元测试测「最小的可测单元」,但没人说清楚这个单元是方法、是类、还是一个行为。这个含糊导致两种极端:一种是给每个 getter 写一个测试,一种是干脆把整个 Service 当一个单元,测出来 300 行一个 case。

我用的标准是行为边界:一个测试应该验证一个可独立推理的决策。 更土的说法是——如果一个测试挂了,你打开超过两个文件才能搞清楚为什么,它就不该叫单元测试。

图片

按这个标准,下面这些就不该出现在单测里:跨服务调用的返回值、ORM 映射是不是正确、SQL 条件拼得对不对、JSON 序列化的字段名。这些全是集成测试的活。硬塞进单测的结果就是 mock 出一堆 when(repo.findByXxx()).thenReturn(fakeEntity),然后断言一个你自己造的假数据,循环论证。

顺便说一句私有方法。不要测,一个字都不要。你之所以觉得它需要测,通常是因为它承担了本该属于另一个类的职责,把它提取成一个有公开方法的独立类,问题就消失了,而且提取过程本身往往能让代码变好。这算单元测试的隐藏收益:写不出测试,往往是设计在报警。

CI 从 4 分 20 秒降到 52 秒,我改了什么

测试慢是团队不写单测的头号原因,比「不知道怎么写」还常见。分享几个实测有效的开关,按性价比排序:

  1. 换测试环境。 Jest 从 29 开始默认 testEnvironment 就是 node 了,但很多老项目还在配 jsdom。如果你的测试里没有 DOM 操作,果断用 node 环境。Vitest 同理,默认就是 node,别为了一个 window.localStorage 把全量测试拖进 jsdom,那个差距在我测的项目里是 2.5 倍左右。
  2. 调并发池。 Vitest 1.0 之后默认 pool 是 forks(更稳但更慢),如果你的测试没有进程级的全局状态污染,改成 pool: 'threads' 能快 30%–40%。Jest 侧看 maxWorkers,默认是 CPU 核数减 1,本地跑 8 核机器可以手动提到 --maxWorkers=6
  3. 砍掉 beforeEach 里的重活。 我见过一个项目在每个测试的 beforeEachnew 一个完整的领域对象图,40 个字段全填。改成 beforeAll 建一个只读的模板对象再浅拷贝,或者直接抽成工厂函数按需构造。
  4. 别在单测里真 sleep。 await new Promise(r => setTimeout(r, 1000)) 这种写法,200 个测试就是 200 秒。JS 用假定时器,Java 用注入 Clock,Python 的 time.sleepmonkeypatch 掉。
  5. pytest 的 fixture scope。 默认是 function,改成 session 复用昂贵的初始化,但要注意可变状态别串味。配合 pytest-xdist-n auto 并行,我手上一个 1200 个 case 的 Python 项目从 6 分多降到 1 分 10 秒左右。

图片

优化完这一轮,单元测试的反馈时间进到 1 分钟以内,这时候它才真的变成了开发写代码时的即时报错器,而不是提交前的仪式。

三个月后的真实数字

回到开头那个问题。为什么覆盖率降了 15 个点反而是好事?因为我把大约 76 个测试删了或改写了:那些只断言 assertNotNull 的删掉,那些 mock 了三层的重写成两个(一个纯内存单测 + 一个真连数据库的集成测试,用 Testcontainers 起 PostgreSQL,@ContainerwithReuse(true) 复用容器,本地第二次跑只要十几秒)。

三个月后的数据:测试执行时间从 4 分 20 秒到 52 秒,变异得分从 31% 到 68%,线上 P2 以上事故一个季度 7 个降到 2 个。丑话说在前面,事故减少有一半功劳是把 60% 的假单测换成了真集成测试,不能全记在单元测试头上。

最后三条我自己信但可能有人反对的结论:

  • 删测试比写测试难,也更值钱。 一个团队的单测水平,看它敢删多少比看它写了多少准。
  • 覆盖率门禁应该是「不许下降」,不是「必须高于 X%」。 绝对值门禁只会逼出 CoverageBoosterTest,diff 门禁才逼人思考。
  • 单元测试最大的收益发生在写代码之前。 那个 RefundCalculator 如果先写测试,divide() 少个 scale 参数这件事在敲下第一行实现的时候就会暴露,根本轮不到线上的用户来提醒你。
🏷️ 标签: