分支覆盖率 代码覆盖率
公共代码基金会致力于为国际公共组织(例如地方政府)启用开放和协作的公共目的软件。 为此,我们通过代码库管理在代码库级别支持软件。 我们还发布了公共代码标准 (在撰写本文时,其草稿版本为0.1.4),这有助于开源代码库社区构建可被其他组织成功重用的解决方案。 它包括针对决策者,经理,开发人员,设计师和供应商的指南。
除其他外,该标准解决了代码覆盖率或运行自动化测试套件时执行了多少代码。 这是一种衡量代码包含未检测到的软件错误的可能性的方法。 在标准的“使用持续集成”要求中 ,它说:“ 应该监视源代码测试和文档覆盖范围。” 此外, 检查此要求的指南指出:“代码覆盖率工具检查覆盖率是否为代码的100%”。
在我从事超过二十年的软件开发生涯中,我从事大型和小型代码库的工作,其中一些代码覆盖率很高。 但是我贡献的所有重要代码库都没有报告100%的测试覆盖率。 这使我怀疑是否将遵循“ 检查覆盖率是否为100% ”的指导。
以前,我认为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 )可以很好地完成报告; 因此,此步骤很容易。 我希望开始的覆盖范围会不错-确实如此,但是它有一些未经测试的分支,以及围绕错误情况和错误报告的预计差距。
我使错误报告可插入,因此在以前未经测试的分支中,更容易捕获错误消息并对其进行断言。
由于此代码已经允许malloc和free的可插入实现,因此可以编写一些可以将内存分配失败注入到其中的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%,还是尝试过? 你的经验是什么? 请在评论中分享。
翻译自: https://opensource.com/article/20/4/testing-code-coverage
分支覆盖率 代码覆盖率