• 代码覆盖率真的是衡量软件产品质量的一个强有力的指标吗?
  • 发布于 2个月前
  • 199 热度
    0 评论
  • 奥特蛋
  • 1 粉丝 43 篇博客
  •   
多年来,技术领导者们普遍认为代码覆盖率是衡量软件产品质量的一个强有力的指标。这种观点表面上看起来合理:测试越彻底,代码覆盖率就越高,软件就应该更加健壮、更不容易出错。这个想法已经深深地植根于我们的脑海中。但是,如果我有证据证明代码覆盖率本质上是错误的呢?如果我能通过向你展示一个简单的想法让你不再怀疑我的观点呢?

代码覆盖率
简单地说,代码覆盖率是衡量测试“触及”或“覆盖”了多少代码的一种指标。假设我们的软件产品包含了测试用例,并至少在每次发布之前都执行这些测试。在执行这些测试时,它们会对产品执行操作,从而让代码跑起来。很快,我们就意识到,如果跟踪哪些代码被测试用例执行了就可以度量执行了多少代码。我们把被执行的代码与产品中总代码量的比例叫作“代码覆盖率”。

这是一个非常简单的度量指标。如果我们有 100 行代码,但测试只执行了其中的 75 行,那么我们的代码覆盖率就是 75%。很快,我们就意识到了更重要的事情:如果代码覆盖率不是 100%,那就还有没有被测试执行的代码,就是未经测试的代码!

拥有未经测试的代码是危险的,因为它们可能存在缺陷。此外,它们还可能包含关键的业务功能,如果我们修改了这些代码,可能会丢掉这些功能。所以,拥有高代码覆盖率是必须的。

代码覆盖率谬论
但现在我们的面前摆着一个谬论:我们知道,拥有未覆盖的代码意味着我们的测试可能遗漏了重要的场景,但反过来并不成立。

例如,在前面的例子中,我们的代码覆盖率是 75%。换句话说,这表明 25%的代码行根本没有被任何测试执行过,这显然向我们指出了一个风险点。我们可以肯定地说,这 25%的代码没有经过任何测试验证,因此可能成为缺陷和维护问题的温床。

然而,这也是我们可能会陷入谬论陷阱的地方:虽然我们可以自信地说未经测试的代码隐藏着潜在的错误和对未来开发的阻碍,但我们可以相信反过来也是对的。我们可能会认为覆盖了代码意味着它有更少的错误和更少的维护问题。但是,那只是一种直觉,甚至看起来似乎合理,而事实却并非如此。

事实是,我们可以拥有 100%的代码覆盖率,但仍然有满是错误和难以维护的代码。

一个简单的例子
假设我们有一个简单的计算两数之和的函数:
function addition(a, b) {
  return a + b;
}
能覆盖 100%代码的最简单的测试是怎样的?只需要进行一次加法运算就能覆盖到所有代码:
test('the addition function', () => {
  addition(3, 4);
});
这个测试覆盖了 100%的代码。然而,它是无用的。为什么?如果我们把加法实现改成这样:
function addition(a, b) {
  return a - b;
}
测试仍然可以通过!

如果你是一名程序员,你可能已经知道问题出在哪里。问题不在于代码覆盖率,而在于测试本身。测试确实覆盖了 100%的代码,但它并没有断言或检查任何东西。这就是为什么错误地实现逻辑(用减法代替加法)仍然能够通过测试。所以,这似乎是一个糟糕的例子……不是的。

事实证明,对于这个非常简单的小例子,我们可以很容易地看到测试中的问题。但如果代码库里有成千上万行代码呢?有人能轻松地找出一个没有正确验证其结果的测试吗?这是极不可能的。

所以,测试可能是错误的,断言也可能是错误的,场景可能被忽略,但我们仍然可以吹嘘拥有 100%的代码覆盖率。问题正好出在这里。

问题的根源
这个问题的根源在于,代码覆盖率是关于代码的度量指标,而不是关于业务的。尽管代码覆盖率可能是揭示未被测试的代码的一个很好的指标,但它并不能告诉我们与业务相关的东西以及项目是如何满足业务目标的。

代码覆盖率关注软件测试的技术层面,但不一定会考虑在构建软件时需要满足的更广泛的业务目标和需求。它衡量了被测试代码的范围,但并不能提供关于软件是否真正达到其预期目的、满足用户需求或与更广泛的业务战略保持一致的见解。

代码覆盖率唯一做到的就是评估你是否在测试期间执行了所有代码,而这很容易就可以达到。

规则 1:执行所有方法。为每一个函数编写一个测试用例。这将会覆盖到所有方法。所以,如果你有两个函数,就写两个测试用例。
function one() {
  //堆代码 duidaima.com
}
test('function one', () => {
  one();
});


function two() {
  // ...
}
test('function two', () => {
  two();
});
规则 2:执行所有分支。为每一个条件语句创建一个额外的测试用例,确保它满足条件。这将覆盖所有分支内的所有代码。
function conditional(condition) {
  if (condition) {
    // ...
  } else {
    // ...
  }
}


test('condition true', () => {
  conditional(true);
});
test('condition false', () => {
  conditional(false);
});
到需要注意的是,要达到 100%的代码覆盖率并不总是需要编写额外的测试:
function conditional(condition) {
  if (condition) {
    // ...
  } 
  // ...
}


test('conditional', () => {
  conditional(true);
});
不需要更多的规则了。我已经展示了“if”语句,但“while”和“switch”也是如此。对其他函数的调用已经被规则 1 覆盖了,所以,就些就够了。那么这些规则与业务有什么关系呢?没有。而这就是问题所在。

真实的经历
我想讨论两种不同的情况,代码覆盖率在其中扮演了欺骗性的角色。几年前,在一个聚会上,我遇到了一位在软件开发公司工作的开发人员,他向我讲述了他为 FDA(美国卫生与公众服务部下属的联邦机构食品和药物管理局)开发软件产品的经历。

情况是这样的:FDA 要求 60%的代码覆盖率,而他们的产品没有测试用例,所以代码覆盖率是 0%。

当 FDA 要求 60%的代码覆盖率时,意味着他们希望看到至少 60%的代码在测试期间被执行。这是一种保证软件在不同条件下可以正常运行的方式,或者至少他们希望如此。

那么真实发生了什么?
因为他们没有测试用例,所以开始着手创建测试用例。最初,他们试图创建有意义的测试,彻底检查最关键的功能,并在各种条件下验证正确行为。但随着时间的推移,继续创建测试变得越来越困难,而代码覆盖率几乎没有增加。很快,他们意识到自己在与时间赛跑。

绝望的时刻需要绝望的措施。他们将注意力从创建有价值的测试转移到简单地增加代码覆盖率百分比上。他们执行测试,查看代码覆盖率报告,然后调整测试,最大程度地执行更多的代码,以此来快速提升代码覆盖率。他们不再考虑测试是否有价值,因为他们将数量置于质量之上。

这花了他们 3 个月,他说这是他整个开发职业生涯中最糟糕的经历。你可能在想,这是一种极端的情况,至少,他们的行为是值得怀疑的,而且,这肯定这不是软件行业的常见做法。别着急,再仔细想想。

事实证明,每一个开发人员在每一次交付时都会踩到同样的定时炸弹。所以,如果一个开发人员被迫交付带有测试用例的代码,并要求具备一定的最低代码覆盖率,并且需要在某个截止日期内完成(即使是他们自己估计的),那么前面经历的教训也同样适用。

我的第二次经历就是这样的。不久前,我的一个客户要求我协助他的一个团队进行测试。关于测试有很多需要讨论的地方,感觉测试既费钱又费时间。这家公司要求至少 80%的代码覆盖率,这让我想起了之前的经历。所以,我做了唯一一件合理的事情:我下载了代码,查看了测试。一个小时后,我意识到我无法理解其中的任何一个测试用例。

我运行了测试,它们通过了,然后我开始尝试做一些试验。因为我不明白这些测试用例是如何工作的,所以我拿到了代码并故意破坏它们,结果让我吃惊:尽管代码被破坏了,测试仍然可以通过。代码覆盖率之所以达到要求,并不是因为测试得彻底,而是因为它们只是偶然地执行到了代码。

这两次经历都在告诉我同一个道理,强制要求代码覆盖率可能不是一个好的管理实践。

实验
正如之前所承诺的,我将展示一个实验,一个简单而有效的实验,它将毫无疑问地证明作为管理指标的代码覆盖率是无用的。
它基于 Allen Holub 的观察:

推文内容:我想过写一个自动代码覆盖率生成器,它只创建测试,这些测试通过随机参数调用程序中的每一个函数/方法,并且总是能通过。达到 80%的覆盖率小菜一碟。可见代码覆盖率并不是一个有用的指标。

这个想法很简单,对吧?正如我前面提到的,要达到 100%的代码覆盖率,我们只需要满足两个规则:一个是执行所有函数,一个是执行所有分支。事实证明 Allen Holub 也是这么想的:一个是让测试执行所有函数/方法,一个是通过使用随机参数来覆盖分支。

如果我们真是这么做的,那么这些测试与我们的业务目标会有什么关系呢?一点都没有!它只会毫不留情地运行所有代码,而不考虑我们的业务。所以,问题是:Allen Holub 说的是对的吗?

自动化生成代码覆盖率可能有点困难,但如果我们限定在随机输入的前提下,而不需要分析代码分支,那么它的复杂性就会大大降低。那么,让我们开始实验吧!

在我的第一次尝试中,我选择了 Java。因为 Java 具有反射能力,所以它是一种非常容易用于自动化测试的语言,并且我已经有一些公共代码库可以用来检查生成器。所以,我在这里做了第一次概念验证:https://github.com/drpicox/classroom--cards-game--2022/blob/feature/autotest/src/test/java/com/drpicox/stage1/TestStage1.java

这段简单的代码只创建了具有公共构造函数和无参数的类的所有实例,并执行了所有没有参数的方法。尽管它很简单,但已经达到了 11%的代码覆盖率。这远低于 80%,但这是预料之中的。

到了这里,我意识到我需要执行带有参数的构造函数和方法,而且可以通过“作弊”直接执行私有方法,采用与 Spring 或 JPA 所采用的相同的机制。这打开了一个新的兔子洞。所以,有了指明了正确方向的第一次概念验证,以及可以将这个实验作为学位项目的可能性,我决定将这个实验列入学位项目的通过资格中。

在这里,我必须感谢 Gerard Torrent。他接受了挑战,尽管他们的学位内容几乎与编译器理论无关,但他还是创造了一种不一样的更容易被人们理解的方法。

他不是通过一个单一的测试来遍历所有代码,而是构建了一个代码生成器,为每一个方法和可能的参数创建一个测试。他还添加了其他功能,例如,如果一个方法需要其他对象,就创建它们,经过一轮又一轮的迭代,整体代码覆盖率得到了提升。有时侯他独自工作,有时候与我联手进一步提高覆盖率。我们做到了。

结果
是的,我们做到了。我们达到了 80%的代码覆盖率,甚至更高。我让 Gerard 进行逐步迭代,并获取结果,以便能够更深入地了解代码覆盖率是如何实现的。代码覆盖率是这样逐步实现的:

我的第一个参考实现:11%
执行所有以 null 作为参数的构造函数:20%
只执行 public void 方法:23%
执行所有的 public 方法:50%
执行所有 public 和 private 方法:50%
创建所需参数的实例(不再有 null):65%
为所需的实例创建实例(嵌套):69%
每个参数测试三个不同的值:69%
在可能的情况下使用 Spring 实例化类:85%

需要注意的是,测试私有方法是一种反模式,请不要这么做。它只是本演示的一部分,因为它可以帮助人为地提高代码覆盖率。所以,最后的结果是:85%的代码覆盖率

这是在不考虑业务逻辑的情况下生成代码,然后呢?

结论
Allen Holub 之所以提出 80%的覆盖率,并不是因为他认为这是一个合理的目标,而是因为 80%是大多数公司的普遍要求。事实上,他正在寻找一种方法来证明强制代码覆盖率最低要求是错误的。

现在我们知道,我们可以构建一个简单的库,无论业务是什么,它都可以执行大部分代码,并人为地提高代码覆盖率。我们不需要 AI、花哨的 LLM、代码复杂度分析,只需要随机地执行函数,你就能满足任何一家公司对最低代码覆盖率的要求。即使在那些代码覆盖率可能略高一些的公司,你也可以通过抛出几个手写的手动测试用例来达到额外的覆盖率要求。

那么,将代码覆盖率作为管理指标会为我们带来什么?什么也没有。以前,我们知道开发人员可以在不执行测试的情况下通过伪造来达到更高的代码覆盖率,而现在我们知道我们也可以通过自动化工具来快速提高覆盖率。所以,如果只是通过随机执行代码就能达到很高的代码覆盖率,那么这个指标就没什么用了。

下一步
下一步是什么?现在我们知道代码覆盖率对管理无用,那么我们能做些什么?

首先也是最重要的是:代码覆盖率对开发人员仍然很重要。这已经被包括 Martin Fowler 在内的许多人讨论了很长时间。他在一篇文章中解释说,代码覆盖率的唯一目的是找到未经测试的代码。这有助于开发人员发现他在写代码时犯下的错误和错误假设。如果用对了代码覆盖率,当它处于较低的水平时,有助于引发重要的业务讨论,并揭示新的功能可能性或被人们误解的东西。


其次,我们有 TDD 或 BDD,它们可能是创建测试的唯一合理的方法。开发人员可能被要求在写好代码之后创建测试,其主要问题在于没有人能够确保这些测试可以正确执行。我们需要看到它们失败,并看着新代码如何纠正它们,只有这样才能让我们确信我们正确地创建了这些测试。

最后,我们应该关注业务。测试只有在能够直接帮助我们验证业务逻辑按照预期执行时才有意义。因此,与其依赖只关注代码的晦涩指标,不如选择更关注业务的其他指标,例如业务规则覆盖率:

这也是一个相当简单的指标,与代码覆盖率非常相似,也存在一些问题,但由于它更多地关注业务,所以比代码覆盖率更加有效。
用户评论