代码覆盖率陷阱:优化代码反而降低覆盖率?
维护至少80%的代码覆盖率会影响代码决策,但并非总是向好的方向发展。
“世上有三种谎言:谎言、该死的谎言,还有统计数据”——据称出自马克·吐温
有些广泛使用的数字和计算,在没有额外信息的情况下,会给人错误的印象。以身体质量指数(BMI)为例,它被用作衡量一个人是否处于健康体重的方法。BMI使用个人的身高和体重计算。通常BMI达到25或以上被视为超重。按照这个定义,下面列出的这个人将被视为超重:
人物X: 身高 - 6英尺0英寸(183厘米) 体重 - 230磅(104公斤) BMI - 31.2
如果你获得更多信息——比如这个人作为费城老鹰队的跑卫在2025年赢得超级碗戒指——你可能会得出结论,BMI可能无法准确反映这个人的健康状况。它甚至可能让你质疑BMI是否有任何价值。
这个人就是萨昆·巴克利,至少根据互联网上的信息,这是他的身高、体重和计算的BMI。我可以很自信地说巴克利先生非常健康,而BMI是衡量他健康状况的糟糕指标。BMI可能有用,但单独使用时是一个非常粗糙的指标。它不区分肌肉和脂肪——这是做出更清晰判断所需的额外信息。
本文讨论的是另一个似乎在软件开发中突然获得很多青睐的指标:代码覆盖率。
代码覆盖率如何成为代码质量的代理指标?
我有多次经历,维持至少80%代码覆盖率的重要性影响了我所做的决策、我编写的代码,而且并非总是向好的方向发展。为什么测试覆盖率成为代码质量的代理指标?我阅读了相关问题和历史,进行了一个实验,并得出了一些一般性观察:
- 并非所有文件、功能或应用程序都具有同等价值,但代码覆盖率工具会一视同仁(未经定制)
- 自动化测试并非总是测试应用程序最具成本效益的方式
- 默认的80%最低代码覆盖率是武断的
- 使代码更DRY(不要重复自己)会使代码覆盖率变差
- 代码结构方式可以提高代码覆盖率指标的准确性——使用真实数据和代码的可验证证明
代码覆盖率已经存在多年。然而,它的使用已经增长,似乎排除了所有其他指标。它从一个用于定位需要更多测试的文件的有用工具,转变为代码质量的终极决定因素。
我的假设是,其他人,“专家”,一定比我懂得更多,有一个好的理由,一个量化、验证、数据证明、测试过的理由,我们都试图达到最低测试覆盖率。我搜索了,但找不到任何科学测试的证据将代码覆盖率与软件质量联系起来。
一直存在对过度依赖代码覆盖率的健康怀疑。马丁·福勒十多年前就写过关于100%代码覆盖率但无断言测试的可能性。即使对无断言测试有合理的担忧,我认为还有其他更可能的情况,而不是整个团队的不良行为者编写无断言测试来提供100%代码覆盖率。
个人而言,我发现代码覆盖率指标有用。它使你在编写测试时更容易识别最大的漏洞在哪里。虽然高覆盖率本身并不能验证代码质量,但低覆盖率是一个危险信号。当自动化时,这个指标可以防止开发人员合并任何文件低于最低阈值的拉取请求——粗略地说,是代码质量的守门员。
如果我说我不知道为什么代码覆盖率现在如此受重视,那我就是在撒谎——原因是有许多软件供应商营销和推广使用代码覆盖率作为自动化衡量质量方式的工具。软件团队可以将这些工具之一添加到流水线中,它将充当守门员,防止开发人员合并任何文件低于最低阈值的拉取请求。这些工具的使用正变得越来越普遍。
简而言之,确定代码质量很难。购买自动化并测量单元测试的工具很容易。
我有多次经历,维持至少80%代码覆盖率的重要性影响了我所做的决策、我编写的代码,而且并非总是向好的方向发展。这让我想知道——我们中有多少人被推动做同样的事情?
并非所有东西都具有同等价值,但代码覆盖率一视同仁
关于代码覆盖率的文章范围从"代码覆盖率是有缺陷的指标"到"100%代码覆盖率还不够"。如果你主张至少100%代码覆盖率,那表明你对其有很多信心。我可能把自己放在中间某个位置;它可能是一个粗糙的测量,但我确实相信它有一些价值。
然而,这些文章大多是在真空中写的,以开发人员为中心的观点,好像唯一重要的是代码。它们写得好像不应考虑时间和金钱,每个代码库都是原始且从头开始编写的。这些文章中没有一个提到编写这些测试的成本、处理某些类型错误的风险管理以及所有这些的投资回报。
福勒引用的一篇文章"如何滥用代码覆盖率"写于1997年。
当你使用代码覆盖率作为衡量测试覆盖率的主要(或唯一)指标时,你正在以完全相同的方式评估每个文件——这个文件是否达到最低阈值?这些文章都没有讨论当你将代码覆盖率添加到已经存在多年的代码库时会发生什么,而这正是这些天经常发生的事情。
除了测试之外,没有考虑其他任务,没有对一个功能与另一个功能的优先级排序。你忽略了代码在做什么以及它提供的价值。你对如果某个特定功能失败会发生什么进行零财务评估。你对待每个文件好像它们具有同等价值,每个都必须达到80%的代码覆盖率。加密用户数据的文件?需要80%代码覆盖率。允许用户上传个人资料图片的文件?需要80%代码覆盖率。
如果我要将代码覆盖率工具添加到现有代码库,我宁愿在开始盲目地对每个文件应用最低阈值之前仔细查看结果。我可以绝对自信地说,无论考虑什么应用程序,都有代码值得接近100%代码覆盖率,而其他代码则远低于80%值得。添加守门员以在每个文件中保持相同百分比将导致开发人员为低价值功能编写测试,而不为剩余20%具有更高价值功能的文件编写测试。
其中一些工具可以配置应用程序内不同目录的不同阈值。如果许多团队首先手动识别其软件的最重要部分并仅对这些部分应用最低阈值,他们将获得更高程度的价值。作为使用代码覆盖率工具的多个项目的一部分,我从未见过除整个代码库的默认覆盖率之外的任何东西。
许多项目添加这些工具而不考虑产品本身。在医疗设备上运行的软件与移动约会应用程序之间,拥有数百万付费用户的遗留应用程序与发布其最小可行产品的初创公司之间,使用代码覆盖率应该有所区别。在所有这些情况下,只有一个值:80%。
你的应用程序中有一些功能,如果它们失败,对你的用户、你的责任或你的底线影响很小或没有影响。还有其他功能将是灾难性的。你可以而且应该更聪明地使用你的代码覆盖率工具。请考虑做一些事情,而不是盲目地将默认阈值应用于每个项目和每个文件。
自动化测试并非总是最具成本效益的方式
“当你可以花六小时未能自动化某件事时,永远不要花六分钟手动做它”——张卓伟
遵循投资回报的主题,我确实相信开发人员忘记了基于代码的自动化测试不是测试功能或代码的唯一方式。如果你正在构建基于Web的应用程序,有像Selenium和Cypress这样的工具。如果你正在测试移动应用程序或桌面应用程序,还有其他可用选项。通常这些工具不测量代码覆盖率。
还有老式的方式,涉及实际用户手动验证功能或代码是否工作。考虑为特定功能集编写自动化测试所需的时间与手动测试相同内容所需的时间。
例如,如果你可以在四小时内编写自动化测试(4 X 60 = 240分钟),并在20分钟内从头到尾手动测试相同的功能,通过非常粗略的餐巾纸数学,你可能在软件发布/部署12次后看到投资回报。
我们故意不考虑涉及的其他成本,即手动执行测试的人员成本、人与代码的潜在易错性、在流水线中运行这些测试的云成本。即使没有这些值,也很容易理解自动化测试通过规模经济在此用例中提供好处。根据功能和应用程序的关键性质,通常会在多个地方对同一功能进行测试,例如自动化和手动测试。
有时某个特定功能很难生成自动化测试,手动测试可能更省时,即使考虑规模。我已经记不清被分配的任务中,编写测试比编写代码困难得多的情况。同样的数学适用——想象一个自动化测试需要16小时(两天)X 60分钟 = 960分钟的努力来编写自动化测试。与手动测试相同功能相比,可能只需要五分钟。你需要192次部署才能从该自动化测试中看到投资回报。
有很多次优代码存在。你的自动化测试只能与用于验证的代码一样好。你的整个测试策略只值它们验证的功能。将代码覆盖率工具添加到次优代码库,希望它会神奇地提高应用程序质量,是行不通的。它也很可能使你的开发团队更难使其变得更好。
如果我希望传达什么,那就是代码的结构方式与代码覆盖率数据的准确性有很强的相关性。我在本文末尾有可验证的证明为什么是这样。
80%的阈值从何而来?
我进行了一次义务性的谷歌搜索,希望我能找到关于80%默认阈值背后的一些科学证明的推理。唉,我一无所获。
然而,我确实找到了一些可信的推测,即80%与帕累托原则有关。这将很有意义,除非你真正理解帕累托原则中80%的含义。
帕累托原则规定"大约80%的后果来自20%的原因。“帕累托原则应用于测试的一个例子如下:
“80%的投诉来自20%的重复问题。”
你可能有很多方式将帕累托原则应用于测试。它可能意味着查看80%的投诉,找到相应的代码并增加那里的代码覆盖率和质量。或者可能意味着你将识别为客户提供最大价值的20%代码,并将80%的资源和精力放在那里。正确应用,它将意味着你花更多时间测试重要的东西,如"我们如何处理失败交易”,而花更少时间验证像"暗模式是否工作?“这样的东西。
强制执行80%代码覆盖率完全不是这样做的——它为每个文件中的每行代码分配同等价值。这意味着如果你有一个包含200行代码验证用户信用卡的文件,以及另一个200行允许用户将其默认外观更改为暗模式的文件,你将必须确保每个文件的覆盖率达到80%。很难量化维护一个功能与另一个功能所需的工作量水平,但任何人都应该容易理解一段代码具有显著更多的价值。
使用帕累托原则的80%作为默认最低阈值,在我看来是被误解的、严重误用的,而且坦率地说是可笑的。比误解它并使其成为代码覆盖率的默认更荒谬的唯一事情是盲目信任代码覆盖率作为衡量代码的终极指标。
正是因为这个原因,我相信我们都使用80%是因为帕累托原则。有人在某处读到它,不理解它的含义,喜欢它的声音,并将其设为默认。
我的意思是80%也是大多数美国学校(和其他地方)B等级的最低阈值。没有人想发布C级代码,但要求B作为最低标准到处都感觉足够好。
开发人员现在生活在一个所有拉取请求都被百分比劫持的世界,因为有人误解了帕累托原则或继续以字母等级的方式思考。
使代码更DRY意味着使代码覆盖率更差
随着代码库的发展,出现改进的机会。最常见的实践之一是合并重复的代码。你的代码库可能有一个或多个代码块被复制并粘贴到其他地方。
在多个地方拥有相同的代码通常被视为不良实践,将重复使用的块移动到单个位置是有意义的。该共享代码可能仍在同一文件中或移动到单独的文件中。这是不要重复自己(DRY)的原则,与每次编写(WET)代码相对。
使代码更DRY通常被认为是好事。然而,这是以代码覆盖率下降为代价的。以下是一些假设数字。
想象你有一个包含100行代码的文件,它满足80%代码覆盖率阈值的最低要求。这意味着该文件的80行在测试期间被触及,20行没有被触及。
该文件有一个十行代码的块出现在两个不同的地方,并且目前在你的测试中被覆盖。
DRY原则建议将此代码移动到可重用函数中。然而,这具有降低代码覆盖率的意外效果。新重构的代码可能更好,但它也将减少被测试覆盖的代码行数,而未覆盖的代码将保持相同的覆盖率。没有额外更改,此代码将无法合并。
WET代码:100行,80覆盖 = 80% DRYer代码:90行,70覆盖 = 77.8%
所以现在你有一个选择:
- 添加覆盖先前未覆盖代码的测试
- 保持重复代码原样
有时代码未被覆盖是有充分理由的——它们是最难编写测试的。测试所需的工作量有时超过它应该提供的价值。
这种情况下呈现的数字是假设的,但情况不是。我被分配修复一个错误,在此过程中找到了使代码库更好、更DRY的机会。但我使代码覆盖率更差,它降到了阈值以下。
还有另一个可用选项。
记得你在小学或中学时,被分配了一个页面范围的作业吗?我们许多人倾向于关注最小值。
在写那份报告时,也许你的页数有点少。截止日期临近,也许还有其他作业要做,其他测试要学习。也许你只是懒。重点是有方法扩展你已有的内容以满足最小值:更改字体、增加行距、将一个段落分成两个,使段落的最后一个词转到新行。绝望的人做绝望的事。有足够的勇气,你可能刚好达到目标。
如果你知道什么是可能的,并且对使用该知识没有保留,那么填充已有覆盖的代码块并不难。到处添加几个记录器语句可能就足够了。你也可以减少未覆盖代码中的行数并获得相同的好处。如果你愿意考虑这些次优实践,如何在不编写新测试的情况下使重构代码超过最低阈值似乎非常合理。
我不可能是第一个意识到这一点的人。一点研究证实我不是。然而代码覆盖率最低要求仍然存在。
代码结构方式改变代码覆盖率指标的准确性
关于代码覆盖率有一些好消息。你可以根据代码结构方式提高覆盖率数据的质量。不过有一个问题——它涉及尽可能冗长和明确地编写代码。
在我的整个软件开发职业生涯中,我大部分时间都在做相反的事情。我目睹大多数有经验的开发人员做同样的事情。我见过多少拉取请求,评论是这样的:
“当你可以使用三元运算符时,为什么使用if-else块?”
我这样做已经很长时间了。我个人喜欢使用三元运算符,以及任何减少代码行数的编程语法。
我想运行一个实验来测试将if-else块减少为三元运算符是否影响代码覆盖率。剧透警告:我不确定这种减少总是正确的选择。这个实验的结果和代码覆盖率的突出地位迫使重新评估我在代码语法中重视什么。
所以实验是这样工作的——我写了一个接受三个参数的函数,名为x、y和z,都是布尔值。该函数也返回一个布尔值,如果任何参数为真则返回真,如果它们都为假则返回假。
有四个测试用例:
- X为真
- Y为真
- Z为真
- 所有参数为假
所有代码都用JavaScript编写。我使用默认使用Istanbul的测试框架Jest。
git仓库包含所述函数的多个版本。该函数的每个版本都有自己的测试文件。每个测试文件包含上述相同的四个测试用例。以下是同一函数的两个变体,并排:
condition_control.js
|
|
控制函数的单元测试:
|
|
在控制分支experiment_01_all_tests_active上,当使用显示覆盖率的选项执行测试时,结果将显示所有类别100%代码覆盖率:语句、分支、函数和行。它还将显示没有指定的代码行未被覆盖。
|
|
所以这就是乐趣开始的地方。我们将禁用某些测试,然后执行测试套件以查看禁用测试如何影响代码覆盖率。有多个禁用哪些测试的变体。以下是我使用的变体:
- 执行所有测试用例
- 禁用所有参数为假的测试
- 禁用Y和Z为真的测试
- 禁用除X为真之外的所有测试
- 禁用除Z为真之外的所有测试
如果你下载仓库,你可以自己尝试每个变体/分支。为简洁起见,我们只检查测试覆盖率差异最明显的变体,示例5:禁用除Z为真之外的所有测试。
比较代码覆盖率结果时,condition_control.js文件,具有最简洁代码的版本,显示100%代码覆盖率,尽管四个测试中有三个已被禁用。其他版本更准确地显示代码覆盖率缺失的地方。使用switch语句且每个条件在单独行上的代码版本显示25%分支覆盖率和50%行覆盖率。这似乎表明你的代码越简洁,用次标准测试覆盖率获得夸大的代码覆盖率分数就越容易。更冗长的代码更容易阅读和理解,并且它提供更准确的代码覆盖率数据。
|
|
这是一个非常简单的函数。用其他语言、其他代码覆盖率工具和更复杂的代码尝试它会是一个有趣的练习。取一个具有 somewhat 体面代码覆盖率的现有代码库并重构某些文件使其更冗长,然后执行测试套件以查看它如何影响代码覆盖率,这是有意义的。
结论
测试和代码覆盖率与保险有很多共同点。两者都提供一定水平的安全性并有成本。你为房屋或汽车保险支付的保费必须值得你保险的东西。测试和代码覆盖率工具也有成本;你的开发人员编写测试所需的时间和精力、测试套件在流水线中的执行等。不仅仅是你用于监控代码覆盖率的软件成本。产生的所有成本必须值得你保护的东西的价值。
有一个原因不是每个人都为他们的汽车购买碰撞保险——这种保险有助于在汽车损坏时支付修理或更换费用。这与责任保险不同,责任保险在事故发生时覆盖他人的汽车。如果你有一辆新车并有汽车贷款,责任保险具有成本效益,并且最有可能由给你汽车贷款的人要求。另一方面,如果你的汽车已付清且里程数很高,如果它全损,保险费很可能不值得你的车辆价值成本。
放置一个要求所有代码达到80%代码覆盖率的代码覆盖率工具可能不值得它产生的成本。我怀疑大多数软件团队只考虑他们购买的代码覆盖率工具的成本,而没有对强制执行代码覆盖率将产生的其他成本进行成本效益分析。代码覆盖率不是免费的。