4.2

4.2 测试覆盖率与测试有效性

概述与动机

测试覆盖率衡量的是一个测试套件所执行的代码所占的百分比:行覆盖率、分支覆盖率,或更严格的路径覆盖率。它是本书全书中被跟踪得最广泛的指标之一,计算成本低廉,容易被可视化成一个单一的百分比,因此也是最常被操纵的指标之一,恰恰以主题 1.2 所预测的、任何一旦成为目标的指标都会经历的方式被操纵。一个测试套件可以在几乎不验证任何有意义内容的情况下,达到高覆盖率,因为覆盖率衡量的是代码在一次测试运行中是否被执行了,而不是测试是否真正检查了代码的行为是否正确。

覆盖率与真正的测试有效性之间的这道缺口,不是一个次要的脚注;它是本主题的核心关切。一个调用了一个函数、却对其结果不做任何断言的测试,与一个彻底验证了该函数在各种边界情况下行为的测试,对覆盖率的提升是一样的。本主题所建议的修复方法,变异测试,刻意向代码中引入微小、人为的错误,并检查测试套件是否真的能捕捉到它们,是对这道缺口的直接回应,本主题把它当作覆盖率必要的补充,而不是一个可有可无的附加项。

对大团队而言,覆盖率目标常常被作为一道质量关卡在全组织范围内采纳,这恰恰是主题 1.2 所警告的那种最容易被操纵的、被激励的高可见度指标。设定一个统一覆盖率百分比要求、却没有配上有效性检查的企业和政府组织,实际上正在激励本书所描述的那种阈值操纵模式:纯粹为了达到一个数字而编写的琐碎测试,却没有带来相应的真实缺陷预防改善。

核心原则

  • 覆盖率衡量的是执行,而不是验证。 一行代码被测试运行,对这个测试是否检查了任何关于它的有意义的内容只字未提。
  • 一个没有有效性检查的覆盖率目标,是一个教科书式的古德哈特定律陷阱(主题 1.2):数字改善了,而真正的质量却没有。
  • 变异测试是覆盖率必要的补充, 而不是替代品;两者要一起使用。
  • 覆盖率作为一道下限,比作为一个要最大化的目标更有用。 一个低数字揭示出真正未经测试的代码;追逐100%往往产生递减甚至负的回报。
  • 关键路径的覆盖率,比均匀、统一的覆盖率更重要。 并非所有代码一旦失败,风险都是相等的。

建议

用覆盖率来找出未经测试的代码,而不是作为一个要最大化的目标

把一份覆盖率报告主要当作一张地图,标示出哪些地方完全没有测试,这是真正有用的信息,而不是当作一个要推向100%的分数。零覆盖率的代码是一个真正值得填补的缺口;把覆盖率从85%推到95%的边际价值通常低得多,也常常不值得为此付出的努力,尤其是如果这种努力只是为了达到一个更高的数字而产生了低价值的测试。

让每一个覆盖率目标都与变异测试配对

变异测试工具会自动向你的代码中引入微小的错误,翻转一个比较运算符、改变一个边界条件,然后针对每一个变异后的版本运行你的测试套件。一个能够”杀死”(在面对变异体时失败)大多数变异体的测试套件,是在真正验证行为;一个拥有高行覆盖率、却变异杀死率低的测试套件,是在执行代码,却没有有意义地检查它。这种配对是抵御覆盖率目标操纵最有效的单一护栏,本书建议把它当作标准实践,而不是一种高阶或可选的技术。

优先在关键路径上进行覆盖率和变异测试

并非所有代码都承载相同的风险。一条支付处理路径、一次身份验证检查,或一个数据迁移脚本,理应比一份很少被使用的管理报告接受远更严格的测试。与其在整个代码库上追求统一的覆盖率,不如识别出你风险最高、后果最严重的代码路径,并首先把覆盖率和变异测试的精力集中在那里,把在真正低风险的代码上接受较低的覆盖率,当作一个刻意的、有依据的权衡,而不是一个疏忽。

留意具体的覆盖率操纵模式

覆盖率一旦成为目标,最常见的被操纵方式包括:调用一个函数、却对结果不做任何有意义断言的测试(主题 1.2 的阈值操纵应用在这个指标上),在测试失败时禁用或删除它,而不是修复背后的问题,以及把难以测试的代码完全排除在覆盖率计算之外,而不是解决它为什么难以测试。定期直接审计一批测试样本,阅读它们实际的断言,而不要只信任覆盖率百分比本身。

在你的CI流水线中设定一道覆盖率下限,而不是一道覆盖率上限

配置你的构建流水线,让新代码的覆盖率一旦低于一个约定的下限就导致构建失败,以防止倒退,而不是要求每一次变更都把整体数字推得更高。这种区分很重要:一道下限防止了退步,却没有制造出那种会产生纯粹为了把数字再往上挪一点而编写的低价值测试的、不懈的向上压力。

权衡取舍:利与弊

方案优点缺点
单独的覆盖率百分比便宜、简单,被工具广泛支持容易被操纵;衡量的是执行,而不是验证
覆盖率加变异测试验证测试确实检查了行为,抵御操纵计算成本更高;需要工具投入
在整个代码库上采用统一的覆盖率目标陈述和执行起来简单在低风险代码上浪费精力;相对于其他地方的风险,投入不足
基于风险、以关键路径为先的覆盖率把精力集中在最重要的地方需要判断力才能正确识别出真正的关键路径

核心张力是简单性与诚实性之间的张力。一个单一的覆盖率百分比容易报告,也容易被设定为目标,但正是这种简单性,让它一旦成为一项被激励的数字,就极易被操纵。解决这种张力的办法,是接受变异测试和基于风险的优先排序所带来的额外复杂性,把它当作一个诚实信号的代价,并明确向你的团队传达,为什么一个较低的整体覆盖率数字,只要正确地集中在关键路径上、并由一个强有力的变异杀死率支撑,比一个更高、分布更均匀、却验证得不那么有效的数字更有价值。

与团队讨论的问题

  1. 我们在风险最高的代码路径上的变异杀死率是多少,它与同一代码上的覆盖率百分比相比如何? 一个高覆盖率数字与一个低变异杀死率之间的巨大差距,是覆盖率本身并没有告诉你你以为它告诉了你的东西的最清晰的迹象。

  2. 我们是否曾主要为了提高一个覆盖率数字而编写测试,对它应该验证什么几乎没有真正思考? 在这里要诚实;这种情况发生的频率比团队愿意承认的更高,尤其是在截止日期压力下、一道覆盖率关卡正阻挡一次合并的时候。

  3. 我们的覆盖率投入是集中在我们风险最高的代码路径上,还是不论后果如何都均匀分布? 把你当前的覆盖率分布,对照一次对你代码库的诚实风险评估,寻找其中的错配。

  4. 我们是否曾禁用或删除一个失败的测试,而不是修复它所揭示的底层问题? 这是覆盖率操纵中破坏性最大的一种形式,因为它主动移除了真实的保护,而所报告的覆盖率数字可能几乎不会变动。

  5. 我们的CI流水线是为新代码强制执行一道覆盖率下限,还是不顾收益递减,不断推高一道上限? 讨论你当前的关卡设计究竟营造了正确的激励,防止退步,还是错误的激励,不懈地向上施压、奖励低价值的测试堆砌。

  6. 我们代码库中有哪些代码被排除在覆盖率计算之外,这种排除是合理的,还是在掩盖一个真正的测试缺口? 审查你实际的排除配置;这份清单随时间悄悄增长、却没有人重新审视每一项排除是否依然合理,是很常见的情况。

行业视角

初创公司。 在这个早期阶段,正式的覆盖率目标往往没有必要;把编写测试的精力直接集中在你风险最高、对业务最关键的代码路径上(通常是支付或核心工作流逻辑),而不是在一个仍在快速变化、很快可能会被大幅重写的代码库上追求一个统一的百分比。

小型企业。 大多数CI平台以最低的设置成本自动报告覆盖率;主要用它来发现完全未经测试的关键代码,而不是追逐一个特定的目标百分比,只有当你有工程能力对变异测试所揭示的内容采取行动时,才考虑引入它。

企业。 在这个规模上,统一的、全组织范围的覆盖率目标是一个常见且后果严重的错误,因为它们会同时在数十个团队中激励本主题所描述的那种操纵。建立随服务关键程度而变化的、基于风险的覆盖率预期,并专门为你风险最高的系统投资于变异测试基础设施。

政府。 覆盖率要求有时会作为质量保证的一种生硬、容易明确写下的代理指标出现在采购或合规文档中。在可能的情况下,把任何合同要求的覆盖率百分比,与一项基于变异测试或基于缺陷的有效性要求配对,这样合同上的激励就不会无意中奖励本主题所警告的那种低价值测试堆砌。

案例

企业。 一家电子商务平台的领导层曾为所有新代码设定了一项公司范围的95%覆盖率要求,作为一道硬性的CI关卡强制执行。两年后一次由一波原本被认为已充分测试的代码中出现的生产缺陷所引发的审计,发现代码库大部分区域的变异杀死率低于40%:各团队一直在编写执行了代码路径、却没有对其行为做出有意义断言的测试,纯粹是为了在截止日期压力下满足这道关卡。该公司用一项基于风险分层的政策取代了统一的覆盖率要求:对支付和身份验证代码,要求严格的覆盖率,加上必须达到80%以上杀死率阈值的强制变异测试,而对低风险的内部工具,则设定一道宽松得多的覆盖率下限,这既减少了被浪费的测试精力,也可衡量地改善了真正关键路径上的缺陷率。

政府。 一家公共卫生机构的福利资格系统,根据其开发供应商协议,被合同要求维持90%的测试覆盖率。在一次尽管覆盖率要求已被满足、却仍然发布出去的重大资格计算缺陷之后进行的事后审查发现,负责该问题的具体函数,其覆盖率完全是通过用有效输入调用该函数的测试实现的,却从未测试过边界条件或无效输入,而缺陷恰恰发生在那里。该机构修订后的供应商合同,现在要求任何资格计算代码在覆盖率之外,还必须提供一份有文档记录的变异测试分数,堵上了曾经让合规却无效的测试满足合同要求的那个具体缺口。

商业理由:动机、投资回报与总拥有成本

把覆盖率与变异测试配对的回报,是在表面上的测试质量与实际测试质量之间的差距造成一次生产缺陷之前捕捉到它。上面的电子商务案例清楚地展示了这种模式:一项单独的覆盖率要求曾经产生了一种虚假的安全感,最终被一波缺陷以远高于本可以更早捕捉到这个差距的变异测试投资的代价揭穿。

总拥有成本包括变异测试的计算成本,它比简单的覆盖率埋点运行起来更昂贵,因此通常被保留给关键路径代码,而不是整个代码库,再加上解读和处理结果所需的工程时间。这份成本对风险最高的代码而言尤其合理,因为在那里,测试有效性方面未被察觉的缺口所造成的代价是最高的。

反模式与陷阱

  • 把覆盖率百分比当作一个直接的质量裁决: 它衡量的是执行,而不是验证。
  • 主要为了满足一道覆盖率关卡而编写测试: 产生的正是主题 1.2 所警告的那种低价值、阈值操纵模式。
  • 禁用或删除失败的测试,而不是修复底层问题: 移除了真实的保护,同时对所报告的数字几乎没有影响。
  • 不顾代码风险,套用一个统一的覆盖率目标: 在低风险代码上浪费精力,同时对真正的关键路径投入不足。
  • 让排除清单随时间悄悄增长: 把真正的测试缺口,藏在一个技术上准确却具有误导性的覆盖率数字背后。
  • 追求一道覆盖率上限,而不是一道覆盖率下限: 制造出不懈的向上压力,奖励测试堆砌,而不是真正的验证。

成熟度模型

  • 第一级,启动: 覆盖率不被测量,或者被不一致地测量,没有下限、目标或有效性检查。
  • 第二级,发展: 存在一个覆盖率目标并被跟踪,但没有变异测试或基于风险的优先排序为精力分配提供依据。
  • 第三级,标准化: 覆盖率下限在CI中被一贯地强制执行,基于风险的优先排序指导覆盖率投入集中在哪里。
  • 第四级,管理: 变异测试在关键路径代码上运行,有一个必须与覆盖率一起达到的、被跟踪的杀死率阈值,排除清单被定期审计。
  • 第五级,协奏: 组织能够指出具体的、可以追溯到由变异测试指导的优先排序的缺陷减少,覆盖率和有效性数据共同直接为测试投资决策提供依据。

讨论思路

  1. 我们单一最关键代码路径上的变异杀死率是多少,我们甚至知道它吗?
  2. 我们是否曾经纯粹为了满足一道覆盖率关卡而编写过一个低价值的测试?
  3. 我们当前的覆盖率投入是集中在风险最高的地方,还是均匀分布的?
  4. 目前哪些代码被排除在覆盖率计算之外,这种排除是否依然合理?
  5. 对我们风险最高的系统进行一次变异测试投资,值得其计算成本吗?

要点回顾

  • 测试覆盖率衡量的是执行,而不是验证;一行被覆盖的代码,对它是否被有意义地检查过只字未提。
  • 把覆盖率与变异测试配对,以验证测试确实能捕捉到真实的错误,而不只是运行了代码。
  • 把测试精力集中在关键、高风险的路径上,而不是在整个代码库上追求统一的覆盖率。
  • 把覆盖率用作防止退步的下限,而不是一道要不懈追求最大化的上限。
  • 留意具体的覆盖率操纵模式:低价值的测试、被禁用的失败测试,以及悄悄增长的排除清单。

参考文献与延伸阅读

  • Working Effectively with Legacy Code,Michael Feathers著(针对现有的、难以测试的代码库的测试覆盖率策略)。
  • Jia, Yue, and Mark Harman, “An Analysis and Survey of the Development of Mutation Testing,” IEEE Transactions on Software Engineering (2011):对变异测试技术及其有效性的全面综述。
  • xUnit Test Patterns,Gerard Meszaros著(与编写真正有效、而不仅仅是满足覆盖率的测试相关的测试设计模式)。
  • Accelerate: The Science of Lean Software and DevOps,Nicole Forsgren、Jez Humble、Gene Kim著(测试实践与交付绩效之间的关系)。