单元测试写了三年,我为什么把一半测试删了?

🔑 关键词:单元测试, 测试策略, 重构, 测试维护, 快照测试

📖 摘要:一个程序员从迷信单元测试覆盖率到主动删除一半测试的真实经历。讨论单元测试在业务代码中的失效场景,以及如何用特性测试和契约测试替代。

先说结论

图片

如果现在有人跑来跟我吹他们团队单元测试覆盖率90%,我第一反应不是佩服,而是想看看他们业务代码里是不是塞满了 mock。过去三年我写过两千多个单元测试,最后删掉的大概有一半。不是测试没用,而是大部分测试其实在给重构挖坑,尤其当你测的是内部实现细节而不是行为。

我2021年在做支付清结算系统时,每个支付状态机都有对应的单元测试,断言方法调用次数、mock 掉所有外部依赖,甚至把某个私有方法通过反射拿出来测。结果产品在端午前临时改规则:部分交易允许冲正后重新入账。我这个状态机代码改动量不到200行,但测试挂了412个。因为每个测试都mock了状态流转的内部步骤,一旦某个步骤里加了缓存,所有断言方法调用顺序的测试全炸。

图片

别把单元测试当保险

很多人写单元测试的本质是给自己壮胆:覆盖率高就觉得代码稳了。但覆盖率这个数字本身很虚,它只告诉你哪些行代码被执行过,没告诉你被测的东西是不是有意义。比如你有一个函数,里面先查数据库再调远程服务,最后把结果拼起来。你mock掉数据库和远程服务,然后断言拼接逻辑。这个测试跑得飞快,覆盖率也漂亮,但它验证的业务价值接近于零。真正容易出问题的是数据库查询条件是否写对、远程服务的超时和重试、还有序列化字段跟上游是否match——这些全被mock掉了。

我后来做过一次实验:把同一个模块的测试分别用两种策略写。第一种是传统单元测试,每个类一个测试文件,100% mock外部依赖,覆盖率92%。第二种是只保留关键算法的纯函数测试,以及对外部依赖边界的轻量集成测试(用testcontainers起真实MySQL,对远程接口用wiremock录真实响应),覆盖率只有63%。三个月后引入一个需求改动,第一种策略的测试改了三天才跑通,因为光梳理mock链就花了一上午。第二种策略只改了4个断言,因为业务规则跟边界模拟是分离的。删掉那些为了覆盖率而写的测试之后,反而更容易发现线上问题。

图片

真正该测的是行为和规则

如果你问我单元测试到底测什么,我会说测算法、状态转换和纯业务规则。比如计算折扣、风控评分、汇率换算、库存扣减这类复杂逻辑,没有任何IO,输入输出稳定,这种测试值得写,而且必须写。拿我做过的一个凑单满减功能举例,优惠计算有几十种组合,这种用参数化测试铺个几十条case,每次改动只要有对应的规则用例,跑一遍就有底。但像那种把一个userService里的方法拆成十几个小函数的代码,每个小函数都单独测一遍,其实意义不大。真正容易出错的是函数之间的协作关系,比如事务边界、缓存失效策略、异步回调顺序——这些不是单测能覆盖的,用单测硬测只会让测试成为实现的影子。

图片

后来我养成了一个习惯:每次写测试前先问自己,我是在测实现还是测行为?如果这个测试需要修改或删除时,必须同时调整被测代码的结构才能跑过,那它多半是坏测试。好的测试应该像一条法律条文,只约束行为,不规定执法车要喷什么颜色的漆。比如支付接口返回什么字段、订单取消后库存什么时候释放、优惠券使用条件怎么校验——这些是行为。至于方法内部是先查redis还是先查db,测试不该管。

我用什么替代了半数单测

图片

删掉那些脆弱的单测后,我在项目里增加了三种东西:特性测试(Characterization Test)、状态机图测试和契约测试。特性测试适合老代码,把输入输出对抓下来存成快照,后续改动时diff,只要行为变化能明确解释,就更新快照。我有个结算模块,历史债务清理规则复杂得没人敢动,用特性测试把过去一年的实际业务数据跑进去,生成几百份快照,至少保证改动后不会无声无息地改变历史结果。状态机图测试则是用有限状态机库建模,然后自动生成合法路径和非法路径的用例,比人肉mock状态方法要高效得多,也直观得多。

契约测试我送给合作部门一套基于Pact的方案:我们生产环境跑一个合约broker,每次服务端改动后自动跑consumer端期望的交互内容,不匹配就挂发布流水线。这比单测中mock一个跟真实不一致的第三方响应靠谱得多——以前常常mock里请求参数是A,实际上线后服务端要求的是A+B,单测试根本发现不了。也就是说,删掉一半单测后,线上故障反而减少了两成,发布频率也翻了一倍,因为不用再改那些跟实现绑定的脆断言了。

别让极致覆盖率绑架你

图片

现在很多团队把单测覆盖率卡得很死,新代码必须多少多少,不达标不让合并。这是一种懒政。项目经理看数字,程序员只能造数字。为了达标,有人把getter/setter也写了测试,有些IDE自带一个导出测试模板的东西,点一下生成一堆空测试壳子,覆盖率就虚高。其实有没有想过,那些不需要刻意写测试的地方,恰恰是因为它们太稳定、太简单,不值得用测试去锁住它们?真正的复杂度往往出现在IO边界、分布式状态和时间戳处理上,你费劲写了那么多的纯函数单测,对这类问题一点办法都没有。

我现在给自己定的规矩是:核心算法必须单测,IO边界用集成测试或契约测试,UI状态用可视化回归或E2E,业务主流程用代码评审和特性开关来应对。单测是个好工具,但不是锤子,不要看到什么都是钉子。如果你发现自己的单测里mock满天飞,或者一次重构测试比代码更难改,那就是该动刀的时候了。