确认:代码覆盖率是一个无用的管理指标

乙醇 创建于 6 months 之前

最后更新: 6 months 之前

阅读数: 429

有道理,但是数据上有点薄弱

看标题就知道这篇文章很有意思,作为一个之前专注过做代码覆盖率平台的人,这个观点一直模模糊糊是存在于我的心里的,不过从来没有这么直截了当的说出来,这篇文章的作者有理有据,值得一看。原文在这里: https://drpicox.medium.com/confirmed-code-coverage-is-a-useless-management-metric-35afa05e8549,我只做了一些翻译。

有一种强烈的信念,即代码覆盖率是衡量软件产品质量的重要指标,这个信念多年来一直被技术领导者们毫无疑问地共享。从表面上看,这个理论似乎很有道理:测试越彻底,代码覆盖率越高,因此我们的软件应该更加健壮和无错误。这个观念已经深深植入我们的思维中。但是,如果我能证明代码覆盖率从根本上是错误的呢?如果我能向你展示一个如此简单的想法,让你对此毫无疑问?所以,请做好准备,做好心理准备。

鉴于本文仅展示了哪些指标不适用于管理(尽管对开发人员非常有用),但并未说明应该遵循哪些指标,因此我最近写了一篇后续文章,解释了应该使用哪四个基本指标以及为什么要这样做,这些观点都有科学依据

The Code Coverage 代码覆盖率

代码覆盖率,简单来说,是衡量你的代码有多少被测试所“触及”或“覆盖”的一种度量。我们假设在我们的产品中有测试,并且我们至少在每次发布之前运行这些测试。当这些测试执行时,它们会对产品进行操作,从而使代码执行。很快,我们意识到如果我们追踪哪些代码被测试执行,我们就可以开始衡量有多少代码被执行了。我们将执行的代码与产品中的总代码量的比例称为“代码覆盖率”。

code coverage = executed code by tests / size of the code

这是一个非常简单的度量标准。如果我们有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%的代码,但它没有断言或检查任何东西。这就是为什么在实现上出现错误(减法而不是加法),测试仍然通过的原因。所以,这似乎是一个糟糕的例子... 嗯,其实不是。

原来,对于这个非常简单的小例子,我们很容易就能看出测试的问题。但是,如果代码库有数十万行代码,有人能轻易找出一个没有正确验证结果的测试吗?这是非常不可能的。

所以,测试可能有问题,断言可能是错误的,场景可能被忽视了,然而,我们仍然可以夸耀百分之百的代码覆盖率指标。这正是问题所在。

根本原因

这个问题的根本原因是代码覆盖率是关于代码而不是业务的度量指标。

虽然这是一个很好的指标,可以揭示可能未经测试的代码部分,但它对业务以及项目如何满足业务目标没有任何说明。

代码覆盖率关注软件测试的技术方面,而不一定考虑软件构建的更广泛的业务目标和要求。它衡量了被测试的代码的范围,但并不能揭示软件是否真正达到了预期目的,满足了用户需求,或者与更广泛的业务战略相一致。

代码覆盖率的唯一作用就是评估在测试过程中是否执行了所有的代码。而这是非常容易实现的:

规则1:运行所有的方法。对于每个函数,编写一个执行它的测试。这样可以覆盖所有的方法。所以,如果你有两个函数,就写两个测试。

function one() {
  // ...
}
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%的软件代码被执行。这是一种提供一定保证软件在不同条件下正常工作的方式。至少,这是他们的初衷。

到底发生了什么事情?

因为他们没有测试,所以他们开始创建测试。起初,他们试图创建有意义的测试,彻底检查最关键的功能,并验证在各种条件下的正确行为。但是时间过去了,创建这些测试很困难,代码覆盖率几乎没有增加。很快,他们意识到他们在与时间赛跑。

绝望的时刻需要绝望的措施。他们将注意力从创造有价值的测试转移到了简单地提高代码覆盖率百分比上。他们执行测试,查看代码覆盖报告,并调整测试以通过尽可能多的代码部分,以迅速提高代码覆盖率。他们放弃了对有用测试的考虑,因为他们将数量置于质量之上。

花了三个月的时间,他形容这是他整个开发者生涯中最糟糕的经历。

现在你可能会认为这是一个极端的情况,他们的行为至少是值得质疑的,而且,肯定地说,这在软件行业并不常见。好吧,再想想吧。

原来每个开发者在每次交付时都会面临同样的定时炸弹。

所以,如果开发人员被迫交付带有测试的代码,达到一定的最低代码覆盖率,并在任意截止日期前完成(即使他们自己估计过),之前的经验也同样适用。

这就是我的第二次经历带领我到达的地方。不久前,我的一个客户请求我协助他的一个团队进行测试。关于测试进行了很多讨论,人们普遍认为测试既费钱又耗时。公司要求至少达到80%的代码覆盖率,整个情况开始让我想起了之前的经历。

所以,我做了唯一合乎逻辑的事情:我下载了代码,查看了测试,一个小时后我意识到我无法理解其中任何一个。

我运行了测试,它们通过了,然后我开始进行实验。因为我不理解测试的实际工作原理,所以我拿到了代码并故意破坏了它。结果让我感到惊讶:尽管代码被破坏了,但测试仍然通过。

代码覆盖率的实现并不是因为测试工作做得彻底,而是因为它们偶然地运行了代码。

这两次经历都给了我一个明确的暗示,强制代码覆盖率不可能是一种好的管理实践。

实验

正如承诺的那样,我将展示一个实验,简单而又有效,将无疑证明代码覆盖率作为管理指标是毫无用处的。

这是基于Allen Holub的以下观察

我考虑过写一个自动化的覆盖率生成器,它只会创建测试用例,使用随机参数调用程序中的每个函数/方法,并且总是通过。噔噔!80%的覆盖率!但是覆盖率并不是一个有用的指标。

这个想法很简单,对吧?就像我之前提到的,我们只需要满足两个规则就能实现100%的代码覆盖率:1)执行所有的函数,2)执行所有的分支。嗯,事实证明,Allen Holub的提议正是这样做的:1)让测试执行所有的函数/方法,2)通过使用随机参数来覆盖分支。

在继续之前,如果我们这样做,这种测试会对我们的业务目标有什么说法吗?什么都没有!它只会无情地运行所有的代码,完全不考虑我们的业务。它将成为最懒惰的开发者。

那么,问题是:Allen Holub的是对的吗?

自动化代码覆盖率可能会有一些挑战,但如果我们限制在随机输入的前提下,而不需要分析代码分支,它的复杂性就会大大降低。所以,让我们开始吧!

在我的第一次尝试中,我选择了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(他的LinkedIn链接在这里)。他接受了这个挑战,尽管他们的学位几乎没有涉及编译器理论,但他创造了一种不同的方法,使我们能够更好地理解。

他不是做一个遍历所有代码的单一测试,而是构建了一个代码生成器,为每个方法和可能的参数创建一个测试。他继续添加功能,比如当方法需要其他对象时,他会创建它们,并且通过迭代逐步提高整体代码覆盖率。有时他独自工作,有时我们联手努力,进一步提高覆盖率。哦,天哪,我们做到了。

The Results 结果

是的,我们做到了。我们达到了80%的代码覆盖率,甚至更多。

我要求Gerard逐步迭代并逐步取得结果,这样我们就能更深入地了解代码覆盖率的工作原理。

所以,逐步地,代码覆盖率达到了:

  • 我的第一个参考实现:11%
  • 以空值作为参数执行所有构造函数:20%
  • 只执行公共的void方法:23%
  • 执行所有公共方法:50%
  • 执行所有方法,包括公有和私有方法:50%
  • 创建所需参数的实例(不再有空值):65%
  • 创建所需的实例(嵌套):69%
  • 测试每个参数的三个不同值:69%
  • 尽可能使用Spring来实例化类:85%

请注意,测试私有方法是一种反模式,请不要这样做,但在这个演示中,它是为了人为地增加代码覆盖率而使用的。

所以,最终的结果是:

代码覆盖率达到85%

这是在没有考虑任何业务情况下生成代码。那么,现在怎么办?

结论

艾伦·霍卢布之所以在他的评论中提到80%,并不是因为他认为这是一个合理的目标——也许他认为是——而是因为80%是大多数公司的常见要求。事实上,他是在寻找一种否定强制代码覆盖率最低要求的方式。

所以,现在我们知道我们可以构建一个简单的库,无论你的业务是什么,它都可以执行大部分代码并人为地增加代码覆盖率。我们不需要人工智能,不需要花哨的LLM,也不需要代码复杂性分析,只需随机执行函数,你就能满足任何公司对最低代码覆盖率的要求。

即使在那些代码覆盖率稍高的公司中,你也可以随便添加几个手工测试来达到所需的额外覆盖率。

那么,将代码覆盖率作为管理指标有什么结果呢?没有任何结果。

以前,我们知道开发人员可以伪造来达到更高的代码覆盖率,而不进行实际测试,现在我们也知道一个快速的自动工具可以迅速增加它。

所以,如果仅仅随机执行代码就能达到很高的代码覆盖率,那么这个指标就变得毫无意义。

下一步

下一步是什么?既然我们知道代码覆盖率对管理来说毫无用处,我们现在可以做些什么呢?

首先也是最重要的是:代码覆盖率对开发人员仍然很重要。这一点很久以来已经被很多人提到过,包括Martin Fowler。他在这篇文章中解释了代码覆盖率的唯一目的是找到未经测试的代码。这有助于开发人员发现在编写代码时所犯的错误和错误的假设。正确应用代码覆盖率失败可以引发重要的业务对话,从而揭示新的功能或误解。

根据Martin Fowler的说法,代码覆盖率的目标。来源在这里。 其次,有TDD或BDD。很可能,毫无疑问,这是创建测试的唯一合理方式。当开发人员被迫在编写代码之后创建测试时,主要问题是没有人能确保这些测试能够正确运行。我们需要观察它们的失败,并且我们需要看到新代码如何纠正这些失败,只有这样我们才能确信我们正确地创建了这些测试。

最后,我们应该专注于业务。结束。只有当测试能够直接帮助验证业务提议是否按预期运行时,它才有意义保留。因此,我们可以选择其他更关注业务的指标,而不是仅仅依赖于一个只关注代码的晦涩指标。一个例子是业务规则覆盖率:

business rules coverage % = automated scenarios / total scenarios

这是一个相当简单的度量标准,非常类似于代码覆盖率,有一些共同的问题,但更加有效,因为它专注于业务。我在一些文章中有更多细节。

0