自动化测试三年,我最大的收获不是覆盖率

三年前的那个下午,我把覆盖率报告的截图发到团队群里,数字是 76%。那是我第一次觉得「自动化测试」这件事被我做成了——从 41% 爬到 76%,两个月,一百多个用例,流水线从灰变绿。我在周会上讲这件事的时候,语气是上扬的。

但如果今天有人问我,这三年在自动化测试上最大的收获是什么,我不会说覆盖率。我会说:我学会了判断什么不值得测,也学会了承认有些东西永远测不完。

这不是一句漂亮话。它是被很多东西撞出来的。

覆盖率度量的是执行,不是验证

先把一件事说清楚:覆盖率高不等于测试好,这几乎是老生常谈。但老生常谈之所以是老生常谈,往往是因为大部分人只在道理上同意,在行为上反对。

覆盖率统计的是「哪些代码行在测试运行时被执行过」。它回答的是「这段代码跑过没有」,而不是「这段代码跑对没有」。这两件事之间的距离,比很多团队愿意承认的要远得多。

一个极端的例子:你可以写一个调用所有方法的测试,不做任何断言,覆盖率照样能冲得很高。你也可以把不可测的文件从统计范围里排除,数字立刻好看。这些都是真实发生过、并且被默许的行为,因为它们让数字变好,而数字是要交上去的。

更隐蔽的问题是,覆盖率是个总量指标。它把核心的计价逻辑和一个 getter 方法算在同一个分母里。你为了把数字从 70% 推到 80%,最后写的那 10% 大概率是收益最低的 10%:无逻辑的转发方法、配置类、简单的字段映射。你花了两周,买到了报告上的一行数字,和几乎为零的风险下降。

我现在依然看覆盖率,但我把它当成体温计,而不是体检报告。体温高说明可能有问题,体温正常什么也不能保证。

第一年,我以为我在写测试,其实我在抄实现

第一年我写的绝大多数测试,现在回头看,有一个共同特征:它们把实现原封不动地翻译成了断言。

比如一个计算运费的方法。我会把内部的仓储、内部的计价器、内部的配置读取全部替换成假对象,然后针对每一个分支写一个用例,校验每个假对象被调用的次数和参数。测试很全,覆盖率很高,全部通过。听上去没什么问题。

问题在我第一次重构的时候暴露出来。我把那个方法拆成了三个小类,对外行为一模一样,结果四十多个用例全红了。我花了一整天,做的事情本质上是把测试里所有被调用的期望值,照着新的调用路径重写一遍——也就是把生产代码抄第二遍。

那天我意识到一件挺刺人的事:一套把实现细节钉死的测试,它保护的不是行为,而是现状。它会成为重构的阻力,而不是重构的安全网。它惩罚任何改进。当测试开始妨碍你改好代码,你迟早会开始绕过测试,而绕过测试的习惯一旦养成,就再也回不去了。

第一年我学到的是:测试应该描述「这个系统对外承诺什么」,而不是「这个系统内部现在怎么拼」。好的测试在重构时应该保持绿色,因为重构的定义本来就是「不改变外部行为」。如果一次不改变行为的重构让测试大面积变红,那不是重构太激进,是测试写错了层级。

覆盖率一旦成为目标,它就不再是度量

第二年的某个季度,覆盖率变成了一个考核项。

我不怪管理层,站在他们的位置,想要一个可量化的抓手是合理的。但结果非常典型:那个季度我们的覆盖率涨了十一个百分点,而线上事故的数量没有任何变化。

数字是怎么涨上来的?一部分是真的补了有价值的用例,另一部分是:把几个难以测试的旧模块加进了忽略列表;给一堆只有字段的类补上读取和写入的「测试」;把一些集成用例重新分类,让它们算进统计口径。没有人作弊,每一步单拿出来都说得过去,合起来就是一个被稀释的指标。

这就是那句关于指标的著名论断的现实版:当一个度量标准变成目标,它就不再是好的度量。指标本身没有错,错的是把它当成终点而不是路标。

从那以后,我在团队里推了一个很小的实践:看覆盖率报告的时候,不看总数,只看两件事——「最近三个月新增代码的覆盖率」和「哪几个核心文件是零」。前者反映当下的工程习惯,后者是需要立刻决策的风险点。总数是记账,前面两个才是决策依据。

第二年真正的转折:从验证实现到描述契约

让我写测试的方式发生根本变化的,是一个很朴素的动作:改测试的命名。

以前我叫 testCalc1、testCalc2。后来我强迫自己用一句话写清楚它在保护什么:「订单金额满 100 元时,运费为零」「用户连续三次输错密码后,账号锁定 15 分钟」。

这个改动看起来只是可读性,实际上逼着我换了一个视角。当我要把一句话写成一个测试名的时候,我必须先回答:这条规则是谁定的?边界在哪?边界外面是什么?满 100 元是 100.00 还是 100.01?锁定的 15 分钟从哪一刻开始算,是从第一次输错,还是从第三次?

以前我写测试是为了「覆盖分支」,现在我写测试是为了「回答一个关于规则的问题」。前者是照着代码写,后者是照着业务写。这两种测试在维护成本上的差距,会随着时间越拉越大。

我后来把这套东西总结成三个问题,每次写测试前先问自己:

这条规则如果坏了,谁会先发现?如果答案是「用户」,那它就值得一个自动化测试。

这个失败能不能在本地稳定复现?如果不能,说明测试依赖了外部的不确定因素,得先解决确定性,再谈断言。

这条断言是在描述「必须为真」,还是在描述「恰好为真」?把「恰好」写成「必须」,就是在给未来埋雷。

第三个问题是我踩坑最多的地方。很多断言只是描述了当前实现的一个偶然结果,而不是业务上必须成立的事实。这类测试不会立刻出问题,但它会在半年后某次无关的改动里突然变红,然后有人花两个小时去查,最后得出结论:「这个测试没用了,删了吧。」——每一次这样的删除,都在消耗团队对测试套件的信任。

比覆盖率更值得焦虑的,是信任

说到信任,这是我三年里最深的体会:一个不稳定的测试,比没有这个测试更糟糕。

不稳定的比例是可以算的。假设单个用例因为时序、随机数、网络波动而偶发失败的概率是 1%,听起来很小。但如果流水线里有 50 个这样的用例,一次完整运行至少出现一次假红的概率超过 39%。再加上重试、并发和共享的测试数据库,实际数字会更高。

结果是什么?团队开始习惯性地重跑失败的任务。第一次重跑是理性行为,第十次就是条件反射。而当某一天真的有一个失败的用例被淹没在重跑里,你失去的不只是一次发现问题的机会,你失去的是整套测试的信号价值。

那段时间我做的事不是加测试,而是减测试和维护测试。我们干了几件具体的事:所有涉及时间的逻辑改用可注入的时钟;所有测试数据自带前缀并且用完即删,测试之间不共享任何状态;把依赖外部服务的用例从主流水线里剥离出来,单独跑;给每个偶发失败的用例建一个工单,规定两周内必须处理——要么修好,要么删掉,不允许它一直红着。

「要么修好,要么删掉」这条规则听起来很粗暴,但它救了我们。因为拖着不处理的偶发失败,本质上是在给整个测试套件做慢性投毒。一个测试套件最重要的属性不是覆盖率,也不是数量,而是:它红了的时候,大家相信它。

第三年我才明白:可测试性是一种设计能力

到了第三年,我发现自己判断一段代码好坏的速度变快了,用的方法很简单:我想一下它好不好测。

难以测试的代码,几乎总是有设计问题。它在构造函数里直接连数据库,所以你没发在测试里换掉它;它把当前时间直接读进业务逻辑,所以你没法验证跨天、跨月、跨时区的行为;它把十件事揉在一个方法里,所以任何一个测试都会牵动全部十件事;它依赖全局单例,所以测试之间会互相污染。

反过来,一段容易测试的代码,通常具备这些性质:依赖是显式传入的,副作用被推到边缘,核心逻辑是纯函数。这三条能同时让代码更容易测试、更容易理解和更容易修改。测试不是设计的负担,测试是设计的第一个用户。

我印象最深的一次,是我们重构一个对账模块。重构的驱动力不是性能,也不是需求变更,而是「这个模块没法测」。我们花了三周把时间、文件系统、网络调用全部抽成接口,然后把核心的对账算法变成一组纯函数。重构完成之后,核心逻辑的测试从 0 个变成了 60 多个,跑完只要 200 毫秒,而且这三周里我们顺手发现了两个存在了两年的边界错误。

假如那三周的目标只是把覆盖率从 45% 提到 80%,我们大概率不会做。真实动机是「这东西让我们睡不着觉」。我先有了痛点,才有了重构,覆盖率只是副产品。

所以现在我更愿意说:自动化测试最大的价值,不在于它发现了多少缺陷,而在于它逼着团队把系统的边界想清楚。能想清楚边界,才能写出稳定的测试;能写出稳定的测试,说明系统结构是对的。

一笔必须算的成本账

第二年年底,我做了一件有点扫兴的事:统计了一下我们那一千多个用例的维护成本。

结论是,写测试的成本只是首付,维护才是月供。粗略地看,一个测试的生命周期成本,大概是它初次编写成本的三到五倍。如果需求会变、实现会改、依赖会升级,这个倍数还会更大。

这笔账改变了我的分配方式。我现在会把测试投资按回报粗略分层:

领域逻辑和计价规则,改动会直接影响钱和用户体验,回归成本极高——这里我愿意写到很细,包括边界和异常路径。

数据转换、解析和校验,容易出错且错误很隐蔽——写。

界面布局、样式、文案——基本不写自动化,交给视觉走查和人工确认。

一次性的迁移脚本、生命周期只有两个月的原型——不写,或者只写一个能跑通的冒烟。

变化极其剧烈、每天都在改的模块——等它稳定下来再补,过早固化只会两败俱伤。

测试金字塔也一样,我一直觉得它是个启发,不是教条。它想说的是「越往上越慢、越脆、越贵」,而不是「界面测试不得超过十个」。如果一个业务的核心风险恰恰在端到端流程上,那你就该在那里多投;如果你的服务是纯计算,那就老老实实写单元测试。按风险分配,而不是按信条分配。

承认边界:有些东西不该被自动化

第三年我还学会了一件以前不太愿意承认的事:不是所有东西都值得自动化。

探索性测试、可用性、视觉一致性、文案的语气、一个流程在真实网络下的手感——这些东西的价值恰恰来自「没有人预先规定该怎么做」。你把它们写成脚本,就是在用昨天的假设去检验明天的体验。

我见过团队花三个月做一套视觉回归,最后因为误报太多而没人看;也见过把重型性能测试塞进每次提交的流水线,结果每个开发都要等二十分钟,于是大家开始把提交攒到一起,反馈周期反而变长了。

所以我现在的态度是:先把自动化用在「确定性最强、重复次数最多、失败代价最高」的地方,剩下的交给人和流程。承认一条自动化测试覆盖不到的边界,比假装它覆盖到了要诚实得多,也安全得多。

回到那个数字

那 76% 现在还在我们的报告里,只是我已经很久没盯着它看了。我更关心的变成了另外几个问题:

一次全量跑需要多久?如果需要四十分钟,那它就很难成为开发过程的一部分,只能变成一个事后检查。

一个失败的用例,平均需要多长时间定位到原因?如果需要半小时,那这套件就是在制造负担,而不是消除负担。

有多少用例在过去半年里从来没有失败过?这些是僵尸用例,它们占着运行时间,提供着心理安慰。

一次重构之后,红掉的用例里,有多少真的发现了行为变化,有多少只是噪音?这个比例,才是测试套件的信噪比。

覆盖率某种程度上像体重秤。它会告诉你有没有异常变化,但它不会告诉你身体好不好。减掉十斤可能是健身,也可能是脱水。

所以,三年下来我到底收获了什么

我收获的不是一套工具链,也不是那个百分比。工具会换,框架会过时,覆盖率的口径明年可能又变了。

我收获的是三个判断力。

第一,知道什么值得测。用「这条规则如果坏了,谁会先发现」来排序,而不是用「哪个文件还没被覆盖」来排序。风险驱动,而不是数字驱动。

第二,知道什么该删。一个让人不再相信的测试套件,价值是负的。维护测试和删除测试,是比新增测试更需要勇气的工程决策。

第三,知道确定性是有成本的,而且值得付。可注入的时钟、显式的依赖、隔离的数据、干净的边界——这些让测试变稳的努力,同时也在让系统变好。这是自动化测试给我最大的意外礼物:它不只是一个质量保证手段,它还是一个设计工具。

三年前我以为我在做的事情,是给代码买保险。现在我觉得更贴切的比喻是:自动化测试是团队对「我们相信什么」写下的一份公开声明。你写了什么断言,就说明你相信什么必须为真;你删掉了哪些断言,就说明你已经不再相信它们了。覆盖率只是这份声明的页数,而页数从来不是重点。