提升代码质量反而会降低测试覆盖率?深度剖析代码覆盖率陷阱

本文深入探讨了将代码覆盖率作为代码质量核心指标的弊端,通过真实实验展示了代码结构如何影响覆盖率数据的准确性,并分析了自动化测试的成本效益比,为开发团队合理应用覆盖率工具提供了新视角。

提升代码质量反而会降低测试覆盖率?

维护至少80%的代码覆盖率会影响代码决策,而且并不总是朝着好的方向发展。

“世界上有三种谎言:谎言、该死的谎言和统计数据”——据称出自马克·吐温

有些广泛使用的数字和计算,在没有额外信息的情况下,会给人错误的印象。以身体质量指数(BMI)为例。它被用来衡量一个人是否处于健康体重。BMI是通过个人的身高和体重计算的。通常BMI达到25或以上就被认为是超重。根据这个定义,下面列出的人会被认为是超重:

人物 X: 身高 - 6英尺0英寸(183厘米) 体重 - 230磅(104公斤) BMI - 31.2

如果你获得更多信息——比如这个人是在2025年作为费城老鹰队的跑卫赢得超级碗戒指的——你可能会得出结论,也许BMI并不能准确反映这个人的健康状况。甚至可能让你质疑BMI是否有任何价值。

这个人就是萨奎恩·巴克利,至少根据互联网上的信息,那就是他的身高、体重和计算出的BMI。我可以放心地说,巴克利先生非常健康,而用BMI来衡量他的健康状况是一个非常糟糕的指标。BMI可能有用,但单独来看它是一个非常粗糙的指标。它无法区分肌肉和脂肪——而这正是做出更清晰判断所需的额外信息。

本文讨论的是另一个在软件开发中似乎突然受到广泛青睐的指标:代码覆盖率

我曾多次遇到这样的情况:维持至少80%代码覆盖率的重要性影响了我所做的决策、编写的代码,而且并不总是朝好的方向发展。为什么测试覆盖率成了代码质量的替代指标?我研究了这个问题及其历史。我做了一个实验。我得出了几点总体观察:

  1. 并非所有文件、功能或应用程序都具有同等价值,但代码覆盖率工具会一视同仁(在不自定义的情况下)
  2. 自动化测试并不总是测试应用程序最具成本效益的方式
  3. 默认的80%最低覆盖率是武断的
  4. 让代码更“干”(DRY)会降低代码覆盖率
  5. 如何构建代码可以提高代码覆盖率指标的准确性——使用真实数据和代码提供了可验证的证据

为什么代码覆盖率成了衡量代码质量的默认指标?

代码覆盖率已经存在多年。然而,它的使用范围已经扩大,似乎排除了所有其他指标。它已经从用于定位需要更多测试的文件的有用工具,转变成了代码质量的终极决定因素。

我的假设是,肯定有其他人,那些“专家”,知道得比我多,并且有一个很好的理由——一个经过量化、验证、数据证明、测试过的理由——我们都在努力达到最低测试覆盖率。我搜索了一下,没有找到任何科学测试的证据能将代码覆盖率与软件质量联系起来。

一直以来,人们都对过度依赖代码覆盖率持合理的怀疑态度。马丁·福勒在十多年前就曾写过关于可能存在100%代码覆盖率但无断言测试的文章。即使考虑到无断言测试这一合理的担忧,我认为还有其他更可能的情况,而不是整个团队的恶意参与者编写无断言测试来达到100%代码覆盖率。

个人而言,我觉得代码覆盖率指标是有用的。在编写测试时,它能让你更容易地识别出最大的漏洞在哪里。虽然高覆盖率本身并不能验证代码的质量,但低覆盖率是一个危险信号。自动化后,这个指标可以防止开发人员合并任何文件低于最低阈值的拉取请求——粗略地说,这是代码质量的把关人。

如果我说我不知道为什么代码覆盖率现在如此受重视,那就是在撒谎——原因是,有许多软件供应商在营销和推广使用代码覆盖率作为自动化衡量质量手段的工具。软件团队可以在流水线中添加其中一种工具,它将充当把关人,防止开发人员合并任何文件低于最低阈值的拉取请求。这些工具的使用正变得越来越普遍。

简而言之,确定代码质量很难。购买能自动化并测量单元测试的工具则很容易。

我曾多次遇到这样的情况:维持至少80%代码覆盖率的重要性影响了我所做的决策、编写的代码,而且并非朝好的方向发展。这让我想知道——我们中有多少人也正被推动着做同样的事?

并非所有东西都具有同等价值,但代码覆盖率却一视同仁

关于代码覆盖率的文章范围很广,从“代码覆盖率是一个有缺陷的指标”到“100%的代码覆盖率还不够”。如果你主张至少100%的代码覆盖率,那表明你对它非常有信心。我可能把自己放在中间某个位置;它可能是一个粗糙的衡量标准,但我确实认为它有一些价值。

然而,这些文章大多是在真空中写的,带有以开发人员为中心的观点,好像唯一重要的是代码。它们写得好像时间和金钱都不用考虑,每个代码库都是原始的,是从头开始编写的。这些文章中没有一个提到编写这些测试的成本、处理某些类型错误的风险管理以及所有这些投资的回报。

福勒引用的一篇文章《如何误用代码覆盖率》写于1997年。

当你使用代码覆盖率作为衡量测试覆盖率的主要(或唯一)指标时,你是在以完全相同的方式评估每个文件——这个文件是否达到了最低阈值?这些文章都没有讨论当你将一个已经存在多年的代码库添加到代码覆盖率工具中时会发生什么,而这正是现在经常发生的情况。

除了测试之外,没有考虑到其他任务,没有对一个功能与另一个功能的优先级进行排序。你忽略了代码在做什么以及它提供的价值。你对某个特定功能失败会发生什么完全没有进行财务评估。你把每个文件都当作具有同等价值,每个都必须满足80%的代码覆盖率。加密用户数据的文件?需要80%的覆盖率。允许用户上传个人资料图片的文件?需要80%的覆盖率。

如果我要向现有代码库添加代码覆盖率工具,我宁愿在开始盲目地对每个文件应用最低阈值之前,先仔细查看结果。我可以绝对自信地说,无论考虑的是什么应用程序,都有代码值得接近100%的覆盖率,而另一些代码则远低于80%的覆盖率。添加一个把关人来维持每个文件的相同百分比,将导致开发人员为低价值功能编写测试,而不为具有高价值功能的剩余20%文件编写测试。

其中一些工具可以配置为对应用程序内的不同目录设置不同的阈值。许多团队如果首先手动识别其软件中最重要的部分,并仅对这些部分应用最低阈值,将会获得更高程度的价值。我曾参与过多个使用代码覆盖率工具的项目,从未见过除整个代码库的默认覆盖率设置之外的任何其他配置。

许多项目添加这些工具时没有考虑产品本身。对于在医疗设备上运行的软件与移动交友应用,对于拥有数百万付费用户的遗留应用与发布其最小可行产品的初创公司,使用代码覆盖率应该有所区别。在所有这些情况下,只有一个值:80%

你的应用程序中有一些功能,如果它们失败,对你的用户、你的责任或你的盈亏影响甚微或没有影响。而另一些功能则可能是灾难性的。你可以也应该更聪明地使用你的代码覆盖率工具。请考虑做些别的事情,而不是盲目地将默认阈值应用于每个项目和每个文件。

自动化测试并不总是最具成本效益的方式

“当你花六个小时尝试自动化某事却失败时,绝不要花六分钟手动完成它。”——张卓威

为了与投资回报率的主题保持一致,我确实相信开发人员忘记了基于代码的自动化测试并不是测试功能或代码的唯一方法。如果你正在构建一个基于网络的应用程序,有像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)代码。

让你的代码更“干”通常被认为是件好事。然而,这是以代码覆盖率下降为代价的。以下是一些假设的数字。

假设你有一个100行代码的文件,它满足80%代码覆盖率的最低阈值。这意味着在测试过程中,该文件的80行代码被触及,20行没有。

该文件中有一个十行代码的块,出现在两个不同的地方,并且目前在你的测试中已被覆盖。

DRY原则建议将此代码移入一个可重用的函数中。然而,这会产生降低代码覆盖率的意外效果。新重构的代码可能更好,但它也将减少被测试覆盖的代码行数,而未被覆盖的代码将保持相同的覆盖率。不做额外更改,此代码将无法合并。

WET 代码: 100 行, 80 行被覆盖 = 80% DRYer 代码: 90 行, 70 行被覆盖 = 77.8%

所以现在你必须做出选择:

  1. 增加测试来覆盖以前未覆盖的代码
  2. 保持重复代码不变

有时代码未被覆盖是有充分理由的——它们是最难编写测试的。有时,测试所需的工作量超过了它本应提供的价值。

这种情况下给出的数字是假设的,但情况并非如此。我曾被分配修复一个错误,在这个过程中找到了一个让代码库变得更好、更“干”的机会。但我降低了代码覆盖率,使其低于阈值。

还有另一个选择。

还记得你上小学或中学时,老师布置作业要求写一定页数范围吗?我们许多人往往只关注最低要求。

在写报告时,也许你离页数要求还差一点。截止日期快到了,可能还有其他作业要做,其他考试要准备。也许你只是懒。重点是,有办法把你已有的内容拉长以满足最低要求:改变字体、增加行距、将一个段落分成两个,这样段落的最后一个词就换行了。绝望的人会做绝望的事。如果有足够的勇气,你可能刚好达到目标。

如果你知道什么是可能的,并且不介意使用这种知识,那么填充那些已经有覆盖的代码块并不难。在适当的地方添加几个日志记录语句可能就足够了。你也可以减少未覆盖代码的行数,获得同样的好处。这似乎很有道理,如果你愿意考虑这些次优的做法,你可以在不增加新测试的情况下,让重构后的代码超过最低阈值。

我不可能是第一个意识到这一点的人。一点研究证实了这一点。然而,代码覆盖率最低要求依然存在。

如何构建代码会影响代码覆盖率指标的准确性

关于代码覆盖率也有一些好消息。你可以根据代码的结构方式来提高覆盖率数据的质量。不过有一个条件——它需要你尽可能以冗长和明确的方式编写代码。

在我的整个软件开发生涯中,我大部分时间都在做相反的事情。我目睹过大多数经验丰富的开发人员也这样做。我见过多少次拉取请求的评论是这样的: “为什么使用 if-else 块,而不是使用三元运算符?”

我做这个已经很久了。我个人很喜欢使用三元运算符,以及任何能减少代码行数的编程语法。

我想做一个实验来测试将 if-else 块简化为三元运算符是否会影响代码覆盖率。剧透警告:我不确定这种简化总是正确的选择。这个实验的结果以及代码覆盖率的盛行,迫使我重新评估我对代码语法的重视程度。

实验是这样的——我编写了一个函数,它接受三个参数,名为 x、y 和 z,它们都是布尔值。该函数也返回一个布尔值,如果任何参数为 true 则返回 true,如果它们都为 false 则返回 false。

有四个测试用例:

  1. X 为 true
  2. Y 为 true
  3. Z 为 true
  4. 所有参数都为 false

所有代码都是用 JavaScript 编写的。我使用 Jest 测试框架,它默认使用 Istanbul。

Git 仓库包含了所述函数的多个版本。该函数的每个版本都有自己的测试文件。每个测试文件都包含上述相同的四个测试用例。以下是同一函数的两个变体,并排显示:

condition_control.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
export const conditionControl = (x, y, z) => {
 return x || y || z;
};
export const conditionSwitchSeparate = (x, y, z) => {
 switch (true) {
   case x:
     return true;
   case y:
     return true;
   case z:
     return true;
   default:
     return false;
 }
};

控制函数的单元测试:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { conditionControl } from "./condition_control";

describe("validate function conditionIfElse", () => {
 xit("should return true when X is true and other params are false", () => {
   const xIsTrue = conditionControl(true, false, false);
   expect(xIsTrue).toBe(true);
 });

 xit("should return true when Y is true", () => {
   const yIsTrue = conditionControl(false, true, false);
   expect(yIsTrue).toBe(true);
 });

 it("should return true when Z is true", () => {
   const zIsTrue = conditionControl(false, false, true);
   expect(zIsTrue).toBe(true);
 });

 xit("should return false when all params are false", () => {
   const allFalse = conditionControl(false, false, false);
   expect(allFalse).toBe(false);
 });
});

在控制分支 experiment_01_all_tests_active 上,当使用显示覆盖率的选项执行测试时,结果显示所有类别(语句、分支、函数、行)的代码覆盖率均为 100%。它还将显示没有指定的代码行未被覆盖。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
jaredtoporek@Jareds-Laptop stackoverflow_code_coverage_experiment % npm test -- --coverage                      
> stackoverflow_code_coverage_experiment@1.0.0 test
> node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage
(node:72128) ExperimentalWarning: VM Modules is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
 PASS  src/condition_if_else_then_return_separate.test.js
 PASS  src/condition_switch_separate.test.js
 PASS  src/condition_control.test.js
 PASS  src/condition_switch_grouped.test.js
 PASS  src/condition_if_else_separate.test.js
 PASS  src/condition_if_else_grouped.test.js
-------------------------------------------|---------|----------|---------|---------|-------------------
File                                       | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
-------------------------------------------|---------|----------|---------|---------|-------------------
All files                                  |     100 |      100 |     100 |     100 |                   
 condition_control.js                      |     100 |      100 |     100 |     100 |                   
 condition_if_else_grouped.js              |     100 |      100 |     100 |     100 |                   
 condition_if_else_separate.js             |     100 |      100 |     100 |     100 |                   
 condition_if_else_then_return_separate.js |     100 |      100 |     100 |     100 |                   
 condition_switch_grouped.js               |     100 |      100 |     100 |     100 |                   
 condition_switch_separate.js              |     100 |      100 |     100 |     100 |                   
-------------------------------------------|---------|----------|---------|---------|-------------------
Test Suites: 6 passed, 6 total
Tests:       24 passed, 24 total
Snapshots:   0 total
Time:        0.235 s, estimated 1 s

这就是有趣的地方。我们将禁用某些测试,然后执行测试套件以查看禁用测试如何影响代码覆盖率。我使用了多种禁用测试的组合变体:

  1. 执行所有测试用例
  2. 禁用“所有参数都为 false”的测试
  3. 禁用“Y 为 true”和“Z 为 true”的测试
  4. 禁用除“X 为 true”之外的所有测试
  5. 禁用除“Z 为 true”之外的所有测试

如果你下载仓库,可以自己尝试每个变体/分支。为了简洁起见,我们只研究测试覆盖率差异最显著的变体,例5:禁用除“Z 为 true”之外的所有测试。

比较代码覆盖率结果时,condition_control.js 文件(代码最简洁的版本)显示 100% 的代码覆盖率,尽管四个测试中有三个被禁用。其他版本则更准确地显示了代码覆盖率缺失的地方。使用 switch 语句且每个条件单独一行的代码版本显示分支覆盖率为 25%,行覆盖率为 50%。这似乎表明,代码越简洁,越容易在测试覆盖率不达标的情况下获得夸大的代码覆盖率分数。更冗长的代码更容易阅读和理解,并且能提供更准确的代码覆盖率数据。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
jmtop@Jareds-Laptop stackoverflow_code_coverage_experiment % git checkout experiment_05_disable_all_tests_except_z_is_true
Switched to branch 'experiment_05_disable_all_tests_except_z_is_true'
jmtop@Jareds-Laptop stackoverflow_code_coverage_experiment % npm test -- --coverage                                       

> stackoverflow_code_coverage_experiment@1.0.0 test
> node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage

(node:70912) ExperimentalWarning: VM Modules is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
 PASS  src/condition_if_else_then_return_separate.test.js
 PASS  src/condition_switch_separate.test.js
 PASS  src/condition_if_else_separate.test.js
 PASS  src/condition_switch_grouped.test.js
 PASS  src/condition_if_else_grouped.test.js
 PASS  src/condition_control.test.js
-------------------------------------------|---------|----------|---------|---------|-------------------
File                                       | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
-------------------------------------------|---------|----------|---------|---------|-------------------
All files                                  |   66.66 |    53.57 |     100 |   66.66 |                   
 condition_control.js                      |     100 |      100 |     100 |     100 |                   
 condition_if_else_grouped.js              |      75 |       80 |     100 |      75 | 5                 
 condition_if_else_separate.js             |    62.5 |       50 |     100 |    62.5 | 3,5,9             
 condition_if_else_then_return_separate.js |   66.66 |       50 |     100 |   66.66 | 4,6,10            
 condition_switch_grouped.js               |      75 |       25 |     100 |      75 | 8                 
 condition_switch_separate.js              |      50 |       25 |     100 |      50 | 4-6,10            
-------------------------------------------|---------|----------|---------|---------|-------------------

Test Suites: 6 passed, 6 total
Tests:       18 skipped, 6 passed, 24 total
Snapshots:   0 total
Time:        0.245 s, estimated 1 s
Ran all test suites.

这是一个非常简单的函数。用其他语言、其他代码覆盖率工具和更复杂的代码来尝试会很有趣。合理的做法是,拿一个现有代码覆盖率尚可的代码库,重构某些文件使其更冗长,然后执行测试套件看看它如何影响代码覆盖率。

结论

测试和代码覆盖率与保险有很多共同之处。两者都提供一定程度的安全性,并且都有成本。你为房屋或汽车保险支付的保费必须与你投保的物品价值相称。测试和代码覆盖率工具也有成本;你的开发人员编写测试所需的时间和精力、流水线中测试套件的执行成本等等。这不仅仅是你用来监控代码覆盖率的软件成本。所产生的所有成本必须与你所保护的东西的价值相称。

不是每个人都为他们的车购买碰撞险(帮助支付修理或更换受损汽车费用的保险)是有原因的。这与责任险(发生事故时涵盖他人车辆的保险)不同。如果你有一辆新车并且有汽车贷款,责任险具有成本效益,并且很可能由给你汽车贷款的人要求购买。另一方面,如果你的车已经付清并且里程数很高,保险费很可能不值得汽车在完全报废时的价值。

设置一个要求所有代码达到80%覆盖率的代码覆盖率工具,可能不值得它所招致的成本。我怀疑大多数软件团队只考虑他们购买的代码覆盖率工具的成本,而没有对强制执行代码覆盖率将产生的其他成本进行成本效益分析。代码覆盖率不是免费的。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计