代码覆盖率陷阱:为何优化代码反而降低测试覆盖率
“世界上有三种谎言:谎言、该死的谎言和统计数据” - 据称出自马克·吐温
某些广泛使用的数字和计算,在没有额外信息的情况下,可能会给人错误的印象。以身体质量指数(BMI)为例,它被用作衡量一个人是否处于健康体重的方法。但如果告诉你这个BMI为31.2的人实际上是2025年费城老鹰队的跑卫,刚赢得超级碗戒指,你可能会得出结论:BMI可能无法准确反映这个人的健康状况。
代码覆盖率:软件开发的BMI指标
本文讨论的是另一个似乎在软件开发中突然获得广泛青睐的指标:代码覆盖率。
我有多次经历,维持至少80%代码覆盖率的要求影响了我所做的决策和我编写的代码,而且并不总是往好的方向发展。为什么测试覆盖率成为了代码质量的代理指标?我阅读了相关问题和历史,进行了实验,并得出了一些普遍观察:
- 并非所有文件、功能或应用都具有同等价值,但代码覆盖率工具会一视同仁(未经定制时)
- 自动化测试并非总是测试应用最具成本效益的方式
- 默认的80%最低代码覆盖率是武断的
- 使代码更DRY(不要重复自己)会降低代码覆盖率
- 代码结构方式可以提高代码覆盖率指标的准确性 - 使用真实数据和代码可验证的证据
代码覆盖率为何成为代码质量的首选指标?
代码覆盖率已经存在多年。然而,它的使用范围扩大了,似乎排除了所有其他指标。它从用于定位需要更多测试的文件的有用工具,转变为代码质量的终极决定因素。
我的假设是,其他人、“专家"一定比我懂得更多,有一个很好的理由,一个量化、验证、数据证明、经过测试的理由,让我们都试图达到最低测试覆盖率。我搜索了,但找不到任何科学测试的证据将代码覆盖率与软件质量联系起来。
并非所有东西都具有同等价值,但代码覆盖率一视同仁
关于代码覆盖率的文章范围从"代码覆盖率是有缺陷的指标"到"100%代码覆盖率还不够”。如果你主张至少100%的代码覆盖率,那表明你对其有很大的信心。我可能把自己放在中间位置;它可能是一个粗略的测量,但我确实相信它有一定价值。
然而,大多数这些文章都是在真空中写的,以开发人员为中心的观点,好像唯一重要的是代码。它们写得好像时间和金钱不需要考虑,每个代码库都是原始的且从头开始编写的。这些文章中没有一个提到编写这些测试的成本、处理某些类型错误的风险管理以及所有这些的投资回报。
当你使用代码覆盖率作为衡量测试覆盖率的主要(或唯一)指标时,你正在以完全相同的方式评估每个文件 - 这个文件是否达到最低阈值?这些文章都没有讨论当你将代码覆盖率添加到一个已经存在多年的代码库时会发生什么,而这种情况如今经常发生。
除了测试之外,没有考虑其他任务,没有对一个功能与另一个功能进行优先级排序。你忽略了代码在做什么以及它提供的价值。你对某个特定功能失败会发生什么进行零财务评估。你对待每个文件都好像它们具有同等价值,每个都必须达到80%的代码覆盖率。加密用户数据的文件?需要80%代码覆盖率。允许用户上传个人资料图片的文件?需要80%代码覆盖率。
自动化测试并非总是最具成本效益的方式
“当你可以花六小时自动化失败时,永远不要花六分钟手动做某事” - Zhuowei Zhang
遵循投资回报的主题,我确实相信开发人员忘记了基于代码的自动化测试不是测试功能或代码的唯一方式。如果你正在构建基于Web的应用程序,有像Selenium和Cypress这样的工具。如果你正在测试移动应用或桌面应用,还有其他可用选项。通常这些工具不测量代码覆盖率。
还有传统的方式,涉及实际用户手动验证功能或代码是否正常工作。考虑为特定功能集编写自动化测试所需的时间与手动测试相同内容所需的时间。
例如,如果你可以在四小时内编写自动化测试(4 X 60 = 240分钟),并在20分钟内从头到尾手动测试相同的功能,通过非常粗略的餐巾纸数学计算,你可能在软件的12次发布/部署后看到投资回报。
80%的阈值从何而来?
我进行了一次义务性的Google搜索,希望我能找到关于80%默认阈值背后的一些科学证明的推理。唉,我一无所获。
然而,我确实找到了一些可信的推测,即80%与帕累托原则有关。这很有道理,除非你真正理解帕累托原则中80%的含义。
帕累托原则规定"大约80%的后果来自20%的原因"。应用于测试的帕累托原则的一个例子如下:
“80%的投诉来自20%的重复问题。”
你可能有很多方式将帕累托原则应用于测试。它可能意味着查看80%的投诉,找到相应的代码并增加那里的代码覆盖率和质量。或者它可能意味着你将识别为客户提供最大价值的20%代码,并将80%的资源和精力放在那里。正确应用时,它将意味着你花更多时间测试重要的东西,比如"我们如何处理失败交易",而花更少时间验证像"深色模式是否工作?“这样的东西。
强制执行80%代码覆盖率完全不是这样做的 - 它为每个文件中的每行代码分配同等价值。这意味着如果你有一个包含200行代码验证用户信用卡的文件,另一个200行允许用户将其默认外观更改为深色模式的文件,你必须确保每个文件的覆盖率达到80%。很难量化维护一个功能与另一个功能所需的工作量水平,但任何人都应该容易理解一段代码具有显著更多的价值。
使代码更DRY意味着使代码覆盖率更差
随着代码库的发展,会出现改进的机会。最常见的实践之一是合并重复的代码。你的代码库可能有一个或多个代码块被复制并粘贴到其他地方。
在多个位置拥有相同的代码通常被认为是糟糕的实践,将重复使用的块移动到单个位置是有意义的。该共享代码可能仍在同一文件中或移动到单独的文件中。这是不要重复自己(DRY)的原则,与每次编写(WET)代码相对。
使代码更DRY通常被认为是好事。然而,这是以代码覆盖率下降为代价的。以下是一些假设数字。
假设你有一个包含100行代码的文件,它满足80%代码覆盖率阈值的最低要求。这意味着该文件的80行在测试期间被触及,20行没有。
该文件有一个十行代码的块出现在两个不同的地方,并且目前在测试中被覆盖。
DRY原则建议将此代码移动到可重用函数中。然而,这具有降低代码覆盖率的意外效果。新重构的代码可能更好,但它也会减少被测试覆盖的代码行数,而未覆盖的代码将保持相同的覆盖率。没有额外的更改,此代码将无法合并。
WET代码:100行,80覆盖 = 80% DRYer代码:90行,70覆盖 = 77.8%
所以现在你必须做出选择:
- 添加覆盖先前未覆盖代码的测试
- 保持重复代码不变
有时代码未被覆盖是有充分理由的 - 它们是最难编写测试的。测试所需的工作水平有时超过了它应该提供的价值。
代码结构方式改变代码覆盖率指标的准确性
关于代码覆盖率有一些好消息。你可以根据代码结构方式提高覆盖率数据的质量。不过有一个问题 - 它涉及尽可能冗长和明确地编写代码。
在我整个软件开发职业生涯中,我基本上一直在做相反的事情。我目睹大多数有经验的开发人员做同样的事情。我见过多少次拉取请求中的评论是这样的:
“当你可以使用三元运算符时,为什么要使用if-else块?”
我这样做已经很长时间了。我个人喜欢使用三元运算符,以及任何减少代码行数的编程语法。
我想进行一个实验来测试将if-else块减少为三元运算符是否影响代码覆盖率。剧透警告:我不确定这种减少总是正确的选择。这个实验的结果和代码覆盖率的突出地位迫使我重新评估我在代码语法中重视什么。
所以实验是这样进行的 - 我写了一个接受三个参数的函数,名为x、y和z,都是布尔值。该函数也返回一个布尔值,如果任何参数为真则返回true,如果它们都为假则返回false。
有四个测试用例:
- X为真
- Y为真
- Z为真
- 所有参数为假
所有代码都用JavaScript编写。我使用默认使用Istanbul的测试框架Jest。
Git仓库包含所述函数的多个版本。该函数的每个版本都有自己的测试文件。每个测试文件包含上述相同的四个测试用例。
在控制分支experiment_01_all_tests_active上,当使用显示覆盖率的选项执行测试时,结果将显示所有类别的100%代码覆盖率:语句、分支、函数和行。它还将显示没有指定的未覆盖代码行。
这就是乐趣开始的地方。我们将禁用某些测试,然后执行测试套件以查看禁用测试如何影响代码覆盖率。有多个禁用哪些测试的变体。以下是我使用的变体:
- 执行所有测试用例
- 禁用所有参数为假的测试
- 禁用Y和Z为真的测试
- 禁用除X为真外的所有测试
- 禁用除Z为真外的所有测试
比较代码覆盖率结果时,condition_control.js文件,具有最简洁代码的版本,显示100%代码覆盖率,尽管四个测试中有三个已被禁用。其他版本更准确地显示了代码覆盖率缺失的位置。使用switch语句且每个条件在单独行上的代码版本显示25%分支覆盖率和50%行覆盖率。这似乎表明,代码越简洁,就越容易通过次标准测试覆盖率获得夸大的代码覆盖率分数。更冗长的代码更容易阅读和理解,并且它提供更准确的代码覆盖率数据。
结论
测试和代码覆盖率与保险有很多共同点。两者都提供一定水平的安全性并有成本。你为房屋或汽车保险支付的保费必须值得你保险的东西。测试和代码覆盖率工具也有成本:开发人员编写测试所需的时间和精力,测试套件在流水线中的执行等。不仅仅是你用于监控代码覆盖率的软件成本。产生的所有成本必须值得你保护的东西的价值。
并非每个人都为汽车购买碰撞保险是有原因的 - 这种保险有助于在汽车损坏时支付维修或更换费用。这与责任险不同,责任险在事故发生时覆盖他人的汽车。如果你有一辆新车并有汽车贷款,责任险具有成本效益,并且很可能由给你汽车贷款的人要求。另一方面,如果你的汽车已付清且里程数很高,保险费很可能不值得你的车辆如果全损的价值。
实施一个要求所有代码达到80%代码覆盖率的代码覆盖率工具可能不值得它产生的成本。我怀疑大多数软件团队只考虑他们购买的代码覆盖率工具的成本,而没有对强制执行代码覆盖率将产生的其他成本进行成本效益分析。代码覆盖率不是免费的。