代码覆盖率原理
1 简介
代码覆盖率是软件测试中用来评估测试用例效果的一个重要指标,表示被测试代码中实际执行的部分与总代码的比率。它能帮助开发者了解哪些代码得到了测试,哪些部分未被覆盖,从而改进测试用例的设计和执行。通过代码覆盖率的分析,开发者可以发现潜在的缺陷和未被测试的代码,从而提高软件质量和测试效率。
覆盖率统计的口径不同,最后统计的覆盖率的数据也不同,一般分为:
语句覆盖: 计算执行的语句数与总代码行数的比率。
分支覆盖: 计算执行的分支数量(如条件判断中的真和假)与总分支数量的比率。
路径覆盖: 计算被测试的执行路径与所有可能路径的比率,通常较为复杂。
条件组合覆盖: 计算执行的条件组合与所有可能条件组合的比率。
函数覆盖: 计算执行的函数数量与总函数数量的比率。
代码覆盖率测试虽然能够帮助开发者发现问题,但也并不意味着代码覆盖率没问题就意味着测试用例没问题。覆盖率高并不代表测试用例完全覆盖了所有可能的情况,还需要结合其他测试方法和技术来保证测试的全面性和有效性。比如:
边界条件覆盖:测试输入的边界值,如最大值、最小值、0、空值等;
异常处理路径:验证异常情况的处理;
参数组合测试:测试不同参数组合的情况;
断言有效性:确保测试用例中的断言条件得到满足。
同时,不同场景下的代码覆盖率要求也不同。比如:
单元测试:关注代码的基本执行路径,通常使用分支覆盖(Branch Coverage);
集成测试:关注模块间的交互路径,使用路径覆盖(Path Coverage);
系统测试:关注整个系统的功能,使用行覆盖(Line Coverage)。
2 主流工具
市面上不论开源还是商业软件,都有很多代码覆盖率工具,下面主要介绍几种主流的代码覆盖率工具。
GCOV:gcov 是 Linux 下 GCC 自带的一个 C/C++ 代码覆盖率分析工具,它可以在编译时插入计数器,在程序运行时记录代码的执行情况,并生成代码覆盖率报告;
LLVM Coverage:LLVM 是一个开源的编译器基础设施项目,它提供了一套用于构建编译器和工具链的框架。LLVM 提供了一个名为 LLVM Coverage 的功能,用于在编译时插入计数器,在程序运行时记录代码的执行情况,并生成代码覆盖率报告;
BullseyeCoverage:BullseyeCoverage 是一个商业的代码覆盖率分析工具,它可以在编译时插入计数器,在程序运行时记录代码的执行情况,并生成代码覆盖率报告;
OpenCppCoverage:OpenCppCoverage 是一个开源的 C++ 代码覆盖率分析工具,它可以在编译时插入计数器,在程序运行时记录代码的执行情况,并生成代码覆盖率报告;
工具名称 |
支持语言 |
插桩方式 |
输出格式 |
跨平台 |
测试粒度 |
性能损耗 |
调试支持 |
|---|---|---|---|---|---|---|---|
GCOV |
C/C++ |
源码插桩 |
文本/HTML |
Linux |
行/分支覆盖 |
低 |
一般 |
LLVM Coverage |
C/C++/Rust等 |
源码插桩 |
HTML/XML |
跨平台 |
边缘覆盖 |
中 |
完善 |
BullseyeCoverage |
C/C++/C# |
二进制插桩 |
自定义 |
跨平台 |
MC/DC覆盖 |
高 |
完善 |
OpenCppCoverage |
C++ |
二进制插桩 |
HTML |
Windows |
行覆盖 |
中 |
基础 |
字段说明:
测试粒度:行覆盖(基础路径)、分支覆盖(条件判断)、边缘覆盖(LLVM)、MC/DC(条件组合覆盖)
性能损耗:低(<5%)、中(5-20%)、高(>20%)
调试支持:基础(崩溃报告)、一般(堆栈跟踪)、完善(可视化调试)
3 代码覆盖率原理
3.1 插桩
代码覆盖率的测量通常通过插桩技术实现,插桩技术主要分为两种类型:
源码插桩:在源代码中插入额外的代码(称为插桩代码),以记录每行或每个基本块的执行情况。这种方法保持了源代码的可读性,生成的报告能够与源代码直接对应。典型的工具比如gcov;
二进制插桩:在编译后的二进制代码中插入额外的代码,以记录每行或每个基本块的执行情况。这种方法通常需要对二进制代码进行修改,可能会对性能产生影响。典型的工具比如LLVM Coverage。
需要注意的是既然是代码插装也就意味着一定对性能有影响,因此覆盖率只能用于功能测试。
不同的工具或者编译器支持的覆盖率测试方案虽然不同,但是其基础原理基本上一致。
比如GCC的gcov在编译时添加 -fprofile-arcs 和 -ftest-coverage 标志。这会在生成的可执行文件中插入代码,以便在运行时记录每个基本块的执行次数。程序执行后gcov 会生成 .gcda 文件,这些文件包含了每个基本块的执行次数。这些数据是后续生成覆盖率报告的基础。
而LLVM 提供了 SanitizerCoverage 选项,用于插桩代码以收集覆盖率信息。通过编译时选项启用后,LLVM 会在生成的代码中插入额外的监控逻辑。SanitizerCoverage 使用 8 位位图来记录控制流图中的边缘覆盖情况。这种方法高效地存储了每条边是否被执行的信息,便于后续分析。覆盖率数据通常以内存映射文件的形式输出,方便在程序运行后进行读取和处理。这样可以有效管理数据并提高性能。
3.2 LLVM实现
代码是否执行到以及执行的次数需要在运行中进行统计,为了能够统计到相关的数据,LLVM在IR中插入计数代码来统计该数据。对于编译器来说,一段程序是否执行到只需要关注入口和出口即可,如果入口执行了出口也执行了那么中间的代码一定也执行了。因此编译器统计覆盖率的基础待单元是每一个基本块(BB,Basic Block),基本块是指一段连续的代码,具有以下特点:
只有一个入口点(第一个指令)。
没有跳转到块内部的指令。
可能有多个出口点(跳转到其他基本块的指令)。
LLVM基本块生成的步骤为:
词法分析:将源代码分解为标记(tokens),识别语法结构。
语法分析:根据语法规则构建抽象语法树(AST),表示程序的结构。
控制流图(CFG)构建:在生成基本块之前,首先构建控制流图。CFG 显示程序的控制流路径,包括基本块;
基本块划分:遍历 AST,根据控制流结构和语句的跳转关系来划分基本块。主要步骤包括:
标识入口: 每个基本块的入口是第一个指令。
确定出口: 识别条件语句、循环和跳转指令,确定基本块的出口。
合并块: 对于连续的无条件跳转的基本块,可以合并,以减少冗余。
生成 LLVM IR:对于每个基本块,生成相应的 LLVM IR 指令,表示该块中的计算和控制流。
其伪代码如下:
function generateBasicBlocks(ast):
blocks = []
currentBlock = createNewBlock()
for statement in ast:
if isJump(statement):
currentBlock.add(statement)
blocks.append(currentBlock)
currentBlock = createNewBlock()
else:
currentBlock.add(statement)
if currentBlock.hasInstructions():
blocks.append(currentBlock)
return blocks
有了基本块,还需要知道控制流图CFG,根据CFG分析不同BB之间执行顺序。控制流图(Control Flow Graph, CFG)是程序中控制流的可视化表示。它由节点和边组成,节点表示基本块,边表示控制流路径。在构建BB时就可以顺势构建出对应的CFG。

覆盖率计数指令的插入通过对每个函数和基本块的遍历,统计后继基本块的数量,并为每个后继基本块创建计数器数组,从而实现对执行情况的精确统计。在插入覆盖率计数指令的过程中,主要步骤如下:
统计后继数:对于每个基本块,统计其后继基本块的数量 n。
创建计数器数组:创建一个大小为 n 的数组 ctr[n],用于记录每个后继的执行次数。 插入计数指令:
对于每个后继基本块 i,根据基本块的条件判断,插入相应的计数指令。具体步骤如下: 在每个基本块的入口处插入一条指令,记录该基本块被执行的次数。根据后继的不同情况,插入不同的条件判断。得到所有所有待检测代码的统计计数后将该数据写文件然后供用户消费。
for each function f in compilation_unit:
write_function_info_to_gcno(f)
for each basic_block bb in f:
n = count_successors(bb)
ctr = create_array(n)
for each successor i of bb:
insert_counting_instruction(ctr[i])
4 基于LLVM实践
LLVM的代码覆盖率使用比较简单,只需要修改编译参数即可,比如这里用下面的代码作为测试代码。要使用启用覆盖率的代码进行编译,向编译器传递 -fprofile-instr-generate -fcoverage-mapping即可。
std::vector<int> GenerateInt(const int count){
std::vector<int> result;
for (int i = 0; i < count; ++i) {
result.push_back(2);
}
return result;
}
void PrintOddVector(const std::vector<int> &vec){
for (const auto& num : vec) {
if(num % 2 != 0){
std::cout << num << " ";
}else{
std::cout<<"\n";
}
}
}
int main(int argc, char **argv){
auto numbers = GenerateInt(20);
PrintOddVector(numbers);
return 0;
}
之后执行程序,当程序退出时,它将把一个 原始配置文件 写入由 LLVM_PROFILE_FILE环境变量指定的路径。如果该变量不存在,则配置文件将写入当前目录的default.profraw。如果LLVM_PROFILE_FILE包含指向不存在目录的路径,则将创建缺少的目录结构。
在生成覆盖率报告之前,必须对原始配置文件进行 索引。这可以使用 llvm-profdata中的 “merge” 工具完成(该工具可以合并多个原始配置文件并在同一时间对它们进行索引),最后使用llvm-cov工具生成报告。
#编译命令
clang++ -fprofile-instr-generate -fcoverage-mapping -std=c++17 main.cpp -o foo
llvm-profdata merge -sparse default.profraw -o main.profdata
#生成报告
llvm-cov report ./foo.exe -instr-profile="main.profdata"
最后通过命令生成报告:
➜ LLVMConverage git:(master) llvm-cov report ./foo.exe -instr-profile="main.profdata"
Filename Regions Missed Regions Cover Functions Missed Functions Executed Lines Missed Lines Cover Branches Missed Branches Cover
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
B:/Code/TestForLearn/Src/LLVMConverage/main.cpp 10 1 90.00% 3 0 100.00% 21 1 95.24% 6 1 83.33%
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
TOTAL 10 1 90.00% 3 0 100.00% 21 1 95.24% 6 1 83.33%
llvm-cov也支持其他参数可以生成更详细的报告(更详细的内容参考llvm文档),比如:
➜ LLVMConverage git:(master) ✗ llvm-cov show ./foo.exe --show-branches=count -instr-profile="main.profdata"
1| |#include <iostream>
2| |#include <vector>
3| |#include <random>
4| |
5| 1|std::vector<int> GenerateInt(const int count){
6| 1| std::vector<int> result;
7| 21| for (int i = 0; i < count; ++i) {
------------------
| Branch (7:21): [True: 20, False: 1]
------------------
8| 20| result.push_back(2);
9| 20| }
10| 1| return result;
11| 1|}
12| |
13| |
14| 1|void PrintOddVector(const std::vector<int> &vec){
15| 20| for (const auto& num : vec) {
------------------
| Branch (15:26): [True: 20, False: 1]
------------------
16| 20| if(num % 2 != 0){
------------------
| Branch (16:12): [True: 0, False: 20]
------------------
17| 0| std::cout << num << " ";
18| 20| }else{
19| 20| std::cout<<"\n";
20| 20| }
21| 20| }
22| 1|}
23| |
24| 1|int main(int argc, char **argv){
25| 1| auto numbers = GenerateInt(20);
26| 1| PrintOddVector(numbers);
27| 1| return 0;
28| 1|}