报告 100% 的代码覆盖率是否合理?

达到报告 100% 代码覆盖率所需的时间远少于我在此探索之前的估计。
72 位读者喜欢这篇文章。
An introduction to GNU Screen

Opensource.com

公共代码基金会致力于为国际上的公共组织(如地方政府)实现开放和协作的公共用途软件。我们通过代码库管理在代码库层面支持软件来实现这一目标。我们还发布了公共代码标准(在撰写本文时为 0.1.4 草案版本),该标准帮助开源代码库社区构建可以被其他组织成功重用的解决方案。它包括针对政策制定者、管理者、开发人员、设计师和供应商的指导。

除其他事项外,该标准还涉及代码覆盖率,即当自动化测试套件运行时,有多少代码被执行。这是衡量代码中是否包含未检测到的软件错误的可能性的方法之一。在该标准的“使用持续集成”要求中,它指出,“应该监控源代码测试和文档覆盖率。” 此外,检查此要求的指南指出,“代码覆盖率工具检查覆盖率是否达到代码的 100%。”

在我的软件开发生涯中,这已经超过二十年了,我曾在大型和小型代码库上工作过,其中一些代码库的代码覆盖率非常高。然而,我参与的非平凡代码库中没有一个报告了 100% 的测试覆盖率。这让我质疑“检查覆盖率是否达到 100%”的指南是否会被遵循。

当我思考我工作过的代码库中测试覆盖率差距的性质时,它们通常围绕着非常难以(在某些情况下,甚至不可能)创建的系统状态。例如,在早期版本的 Java 中,我记得我们被要求为永远不会抛出的异常编写 catch 块。

以前,我认为 100% 的测试覆盖率是应该努力实现的目标,但在大多数代码库上可能不值得付出成本,并且在少数情况下可能不现实。

随着时间的推移,覆盖率工具变得越来越智能,可调整性也越来越强。语言变得越来越轻,库也变得越来越容易模拟和测试。那么,今天 100% 的功能覆盖率有多么不合理呢?

资源耗尽

我贡献的高质量但低测试覆盖率的代码库恰好是用 C 或 C++ 编写的。快速浏览这些代码库表明,有一类常见的低覆盖率情况,我将它们归为资源耗尽的范畴:内存不足、磁盘空间不足等。

这是一个不检查资源耗尽的代码的简单示例;在本例中,是内存分配失败

char *buf = malloc(80);
sprintf(buf, "hello, world");

此示例代码需要分配一个小缓冲区,因此它调用 malloc(80),而 malloc 通常返回一个指向 80 字节内存的指针……但这可能会失败。在(不太可能的)malloc 返回 NULL 的情况下,上面的代码将继续使用 NULL 指针调用 sprintf,这将导致崩溃。在 C 代码中,通常会执行更像这样的操作

char *buf = malloc(80);
if (buf == NULL) {
    fprintf(stderr, "malloc returned NULL for 80 bytes?\n");
    return NULL;
}
sprintf(buf, "hello, world");

此代码防范 malloc 返回 NULL,这更好。然而,为在这种资源耗尽情况下正确行为创建测试可能非常困难。当然,这并非不可能,并且有多种方法。许多方法都会导致脆弱的测试,这些测试需要在一段时间内进行大量维护,并且这些测试在开始时可能会非常耗时。

探索

考虑到这一点,我决定进行一个小实验,看看我是否可以了解有关这个严格的 100% 标准的成本和后果的信息。

由于我做一些嵌入式系统开发,我开发了一些 C 库,并在我的嵌入式项目中重复使用了多年。我决定查看其中一些库,看看将它们提高到 100% 代码覆盖率有多难。在此过程中,我注意到了对代码清晰度、代码结构和性能的影响。

一个具有预先存在的依赖注入的库

第一步是通过向代码库添加代码覆盖率来衡量。由于这是 C,gcc 通过 --coverage 选项默认提供了很多功能,而 lcov(使用 genhtml)在生成报告方面做得很好;因此,这一步很容易。我预计起始覆盖率会非常好——确实如此,但它有一些未经测试的分支,以及围绕错误条件和错误报告的预测差距。

我使错误报告可插拔,因此可以更轻松地捕获和对先前未经测试的分支中的错误消息进行断言。

由于此代码已经允许插入 mallocfree 的可插拔实现,因此可以轻松编写小的 malloc 和 free 包装器,我可以将内存分配失败注入其中。在一两个小时内,这就被覆盖了。

在此过程中,我意识到存在一种情况,从调用客户端代码的角度来看,无法区分错误发生的情况和 NULL 是有效返回值的情况。对于你们 C 程序员来说,它本质上类似于以下情况

/* stashes a copy of the value
 * returns the previously stashed value */
char *foo_stash(foo_s *context,
                char *stash_me,
                size_t stash_me_len)
{
    char *copy = malloc(stash_me_len);
    if (copy == NULL) {
        return NULL;
    }
    memcpy(copy, stash_me, stash_me_len);
    char *previous = context->stash;
    context->stash = copy;
    /* previous may be NULL */
    return previous;
}

我调整了 API,以使错误信息可以显式可用。如果您是 C 开发人员,您知道可以通过多种方式来实现这一点。我选择了一种类似于以下方法的方法

/* stashes a copy of the value
 * returns the previously stashed value
 * on error, the 'err' pointer is set to 1 */
char *foo_stash2(foo_s *context,
                char *stash_me,
                size_t stash_me_len,
                int *err)
{
    char *copy = malloc(stash_me_len);
    if (copy == NULL) {
        *err = 1;
        return NULL;
    }
    memcpy(copy, stash_me, stash_me_len);
    char *previous = context->stash;
    context->stash = copy;
    /* previous may be NULL */
    return previous;
}

如果不测试资源耗尽,我可能需要很长时间才能注意到 API 的这个(现在显而易见的)缺点。

为了使 lcov 报告 100% 的测试覆盖率,我不得不告诉编译器不要内联任何代码,我了解到即使在优化级别为零时它也会这样做。

当嵌入到实际固件中时,编译器优化掉了未使用的间接寻址;因此,源代码中添加的间接寻址在编译后的固件中没有造成实际的性能损失。

当然,这是简单的库。

一个更典型的库

一旦我建立了一种在测试中注入内存分配失败的方法,我决定继续研究另一个库,但这个库的 malloc 和 free 尚未插拔。我有一些疑问。这对代码库的侵入性有多大?它会使代码变得混乱,使其不那么清晰吗?它会耗时多久?

虽然我并不总是记录覆盖率指标,但我非常相信测试:20 多年前,我了解到,如果我在实现代码之前编写测试和客户端代码,我的代码会得到改进,并且我一直以这种方式工作。(在测试驱动开发:示例中,您可以在致谢中找到我的名字。)然而,当我向第二个库添加代码覆盖率报告时,我惊讶地发现(在过去的某个时候)我向该库添加了一对函数,但没有为它们添加测试。其他未经测试的区域不出所料是处理内存分配失败的代码。

当然,为这对未经测试的函数编写测试是快速而简单的。覆盖率工具还显示,我有一个函数,其中一个未经测试的代码分支,仅快速浏览一下,就包含一个错误。修复很简单,但我很惊讶地发现了一个错误,考虑到我使用此库的不同项目。尽管如此,它就在那里,这令人谦卑地提醒我们,错误常常潜伏在未经测试的代码中。

接下来是更具挑战性的内容:测试资源耗尽。我首先为 malloc/free 函数指针引入了一些全局变量,以及一个用于保存内存跟踪对象的变量。一旦它开始工作,我就将这些变量从全局范围移动到已经存在的上下文参数中。重构代码以允许必要的间接寻址只花了几个小时(比我预期的要少),并且增加的复杂性可以忽略不计。

反思

我对第一个库的结论是,这是非常值得的。代码现在更加灵活,API 对于调用者来说现在更加完整,并且编写故障注入工具非常容易。

从第二个库中,我意识到即使是可插拔性较差的代码也可以在不增加过度复杂性的情况下使其可测试。代码得到了改进,我修复了一个错误,并且我可以对代码更有信心。此外,能够插入替代内存分配器的额外模块化是一个功能,将来可能会证明更有价值。

排除注释是 lcov 的一项功能,可使覆盖率报告忽略代码块。有趣的是,我感觉在任何一个库中都没有必要使用排除注释。

我比以往任何时候都更加确信,即使是非常好的代码,通过投资测试覆盖率也会得到改进。

这两个代码库都很小,已经具有一定的模块化,从良好的测试点开始,是单线程的,并且不包含图形 UI 代码。如果我要尝试在我贡献的更大的、更单体式的代码库之一上解决这个问题,那将更困难,并且需要更大的时间投入。可能会有一些代码部分,我可能仍然会得出结论,最好的方法是“作弊”,即调整工具以不报告某些代码部分。

也就是说,我估计达到报告 100% 代码覆盖率所需的时间远少于我在此探索之前的估计。

如果您碰巧是 C 程序员,并且想查看这方面的运行示例,包括 gcov / lcov 用法,我提取了内存不足注入代码,并将其放在示例存储库中。

您是否曾将代码库推送到 100% 的测试覆盖率,或者尝试过这样做?您的经验是什么?请在评论中分享。

接下来阅读什么

2 条评论

非常酷的小型测试覆盖率探索!

我从您的细节中怀疑您在某些事情上长期以来一直很自律。如果所有软件项目都由 TDD 或至少是防御性编程驱动就好了。

我是从覆盖率主题和 QA 角度被吸引到这里的。参与一些大型代码库项目,我很好奇您是如何获得超过 50% 的覆盖率的,更不用说 100% 了!

我赞赏您关于在合理的时间内获得 100% 覆盖率的评论……但是对于具有重大技术债务(纪律较差)的非常大型的项目,我怀疑存在一个临界点,寻求如此高的质量要么是不可能的,要么被认为不值得努力。

但感谢您,我认为我们需要更多关于 QA 工作的文章。

谢谢你 James,不客气。我同意大型项目的开发人员可能会明智地得出结论,100% 的覆盖率可能不是一个值得近期实现的目标。具体来说,我参与的非常大型的代码库之一在大多数衡量标准下都非常高质量,但也存在重大的技术债务和低测试覆盖率。关键开发人员认识到测试覆盖率的价值是改进其代码审查过程的一种方式,因此代码覆盖率报告才刚刚引入。没有人谈论达到特定覆盖率百分比的目标,而是期望覆盖率应该随着时间的推移而提高。我很想看看,在未来几年里,新的代码覆盖率报告将如何影响代码库的设计和质量,以及它将如何影响我们作为开发人员、补丁审查员和架构师。

回复 作者 JamesF

Creative Commons License本作品根据 Creative Commons Attribution-Share Alike 4.0 International License 获得许可。
© . All rights reserved.