报告 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 的情况下,上面的代码将继续调用 sprintf 并使用 NULL 指针,这将导致崩溃。在 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

© . All rights reserved.