我的功能
我有两种协同工作的模式:
-
DEDUP(去重) —— 识别并移除冗余的测试文件/场景(即另一个文件已用相同输入值覆盖了相同的代码路径)。通过 gcov 覆盖率对比(移除前 vs. 移除后)进行验证。
-
GAP-FILL(填补缺口) —— 去重后,分析覆盖率报告,找出未测试或测试不足的代码(头文件和编译的源文件),然后编写有针对性的单元测试来填补这些缺口。通过 gcov 覆盖率改进(填补前 vs. 填补后)进行验证。
两种模式共享一个通用的基准构建和覆盖率测量工作流。
何时使用我
对于 DEDUP:
- 您的 C++ 测试套件有机增长,多个文件覆盖了相同的内容
- 与全面的测试文件(
*_comprehensive.cpp)一起添加了补缺测试文件(*_gaps.cpp) - 您继承了一个包含重复测试场景的代码库
- 您希望缩短 CI 测试时间而不损失覆盖率
对于 GAP-FILL:
- 您的编译后的
.cpp源文件没有对应的测试文件 - 覆盖率报告显示某些头文件的行覆盖率低于 90%
- 您刚刚移除了冗余测试,并希望将节省的测试时间用于填补缺口
- 您需要在发布截止日期前提高覆盖率
请勿在以下情况使用我:
- 测试是参数化/生成式测试同一属性(这些是故意冗余的)
- 项目没有设置覆盖率工具
- 您只需要添加测试,而不先检查冗余
我的工作方式
阶段 0:了解测试环境
读取测试目录和 CMakeLists.txt:
test/source/*.cpp
test/CMakeLists.txt
include/<project>/*.hpp
source/*.cpp # 编译的源文件
检查以下模式:
- 成对模式:
*_gaps.cpp+*_comprehensive.cpp,*_basic.cpp+*_detailed.cpp - 编译后的
.cpp文件在source/中,但没有对应的test_*.cpp include/中的头文件没有专用的测试文件- 测试框架(doctest、Catch2、Google Test 等)和 CMake globbing
注意:
- 测试框架和使用的宏(
TEST_CASE、CHECK_EQ等) - CMakeLists.txt 是否使用
file(GLOB ...)或显式源文件列表 - 覆盖率标志模式(通常为
-DENABLE_TEST_COVERAGE=ON)
阶段 1:在文件级别检测冗余
对于每个候选文件,将其测试场景与套件中的每个其他测试文件进行比较。
具体比较方法:
-
并排读取两个文件。 将测试场景按它们所执行的代码分组:
- 自由函数(gcd、lcm 等)
- 类方法(构造函数、运算符、访问器、修改器)
- 模板实例化和特化
- 边缘情况覆盖(零、无穷大、NaN、边界值)
-
检查值级别的重复。 如果两个文件用 完全相同的值 测试相同的函数(例如,两个文件中都有
gcd_recur(12, 8) = 4),则该场景是冗余的。细微的变化(一个文件中有斐波那契数列,另一个没有)不是冗余的——这些是唯一的输入。 -
将文件标记为“冗余”,如果其 >90% 的测试场景出现在另一个文件中,且后者具有相同或更全面的覆盖率。剩余的 <10% 的唯一场景(细微变化)可以安全地丢弃。
阶段 2:构建基准(移除前)
使用覆盖率检测构建测试套件:
| 工具 | 配置 |
|---|---|
| GCC + gcov | cmake -S test -B build/cov -DENABLE_TEST_COVERAGE=ON -DCMAKE_CXX_COMPILER=g++ -G "Unix Makefiles" |
| Clang + llvm-cov | cmake -S test -B build/cov -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_CXX_FLAGS="-fprofile-instr-generate -fcoverage-mapping" -G "Ninja" |
| MSVC | 使用 OpenCppCoverage 或 VS Enterprise 的内置覆盖率 |
然后构建并运行:
cmake --build build/cov
./build/cov/<TestExecutable>
为库头文件生成覆盖率
cd build/cov
# 对于每个库头文件,针对包含它的测试目标文件运行 gcov
gcov -o CMakeFiles/<Tests>.dir/source/<test_file>.cpp.o <path>/include/<project>/<header>.hpp
# 对于每个编译的源文件,针对库目标文件运行 gcov
gcov -o _deps/<project>-build/CMakeFiles/<Project>.dir/source/<source>.cpp.o <path>/source/<source>.cpp
记录基准指标:
Headers:
array_like.hpp: 100% (28/28)
bpqueue.hpp: 100% (73/73)
...
Compiled sources:
ell1d.cpp: 0% (0/23) ← UNTESTED!
ldlt_mgr.cpp: 100% (10/10)
...
Test cases: N | Assertions: M | Status: SUCCESS
还要记录每个文件的 确切未覆盖行(.gcov 输出中的 #####: 行)。
阶段 3:移除冗余文件(DEDUP 模式)
Remove-Item -Force test/source/test_*_gaps.cpp # PowerShell
git status test/
git diff --stat
阶段 4:重新构建并收集去重后的指标
# 重新配置(CMake GLOB 可能检测到缺失的文件)
cmake -S test -B build/cov -DENABLE_TEST_COVERAGE=ON -DCMAKE_CXX_COMPILER=g++ -G "Unix Makefiles"
cmake --build build/cov
./build/cov/<TestExecutable>
将覆盖率和基准进行比较。如果任何头文件的覆盖率 下降 或未覆盖行 改变,立即回退——该文件并非冗余。
DEDUP 的判定标准:
| 检查项 | 要求 |
|---|---|
| 所有测试通过 | 必须通过(前后都通过) |
| 每个头文件的行覆盖率 | 前后完全相等 |
| 未覆盖的行 | 未覆盖行的集合相同 |
| 测试数量减少 | 预期之中——移除了测试用例 |
| 断言数量减少 | 预期之中——移除了断言 |
阶段 5:分析覆盖率缺口(GAP-FILL 模式)
如果去重后(或从头开始)覆盖率报告显示缺口,则识别未测试的代码:
缺口类型 A:零覆盖率的编译源文件
检查 source/*.cpp 与 test/source/*.cpp。任何 source/ 中没有匹配的 test_*.cpp 的 .cpp 文件很可能未测试。
# 查找没有测试覆盖率的源文件
Get-ChildItem source/*.cpp | ForEach-Object {
$base = $_.Name -replace '\.cpp$'
$test = "test/source/test_$base.cpp"
if (-not (Test-Path $test)) { Write-Host "UNTESTED: $($_.Name)" }
}
缺口类型 B:覆盖率低的库头文件
查看 gcov 报告中行覆盖率低于 90% 或“无可执行行”的头文件。模板密集型头文件可能将覆盖率归因于 .cpp 文件——请同时检查两者。
缺口类型 C:已覆盖文件中的函数/分支
部分覆盖文件中未覆盖的行(gcov 中的 #####:)揭示了未执行的具体分支。这些是最有价值的测试目标。
阶段 6:编写有针对性的测试
6a:读取未测试的源代码
完全读取源文件以了解:
- 类/函数 API(构造函数、方法、参数)
- 所有条件分支(if/else、switch case、早期返回)
- 边缘情况(null/零输入、边界值、错误条件)
- 数学或算法行为
6b:设计覆盖每个分支的测试用例
对于每个方法,枚举所需的测试场景:
| 方法 | 条件 | 输入 | 预期行为 |
|---|---|---|---|
update(cut) | beta > tau | g=1, beta=10,_r=5 | 返回 NoSoln,状态不变 |
update(cut) | beta < -tau | g=1, beta=-10,_r=5 | 返回 NoEffect,状态不变 |
update(cut) | ` | beta | <= tau, g>0` |
update(cut) | ` | beta | <= tau, g<0` |
update_central_cut(cut) | g > 0 | g=1, beta=0 | 将 _r 减半,将中心向左移动 |
update_central_cut(cut) | g < 0 | g=-1, beta=0 | 将 _r 减半,将中心向右移动 |
包括边缘情况:
- 零宽度区间
- 边界值(
beta == tau,beta == -tau) - 零梯度(如果适用)
- 重复/链式操作以验证状态转换
6c:编写测试文件
在 test/source/ 中编写一个新的 test_<module>.cpp,遵循项目现有的测试模式:
#define DOCTEST_CONFIG_NO_EXCEPTIONS_BUT_WITH_ALL_ASSERTS
#include <doctest/doctest.h>
#include <ellalgo/<module>.hpp> // 被测试的头文件
#include <ellalgo/ell_config.hpp> // 如果需要类型定义
#include <utility> // 如果需要 std::pair
TEST_CASE("<Module>: 构造") {
auto x = <Module>(...);
CHECK_EQ(x.some_method(), expected_value);
}
TEST_CASE("<Module>: 成功路径的方法") {
auto x = <Module>(...);
auto status = x.update(...);
CHECK_EQ(status, CutStatus::Success);
CHECK_EQ(x.state(), expected_state);
}
TEST_CASE("<Module>: 失败路径的方法") {
auto x = <Module>(...);
auto status = x.update(...); // 触发 beta > tau 分支
CHECK_EQ(status, CutStatus::NoSoln);
}
TEST_CASE("<Module>: 边缘情况") {
// 零宽度区间、边界条件等
}
TEST_CASE("<Module>: 链式操作") {
// 多次顺序更新
}
测试值的规则:
- 使用 硬编码的预期值(不是从相同的被测代码计算得到的)
- 在注释中用数学推导注释每个预期值(例如,
// tau = |5*1| = 5, bound = 5 - 2/1 = 3, _r = half(3-0) = 1.5, _xc = 1.5) - 每个
if分支至少测试一次 - 每个
switchcase 至少测试一次 - 至少测试一次链式/顺序操作以验证状态持久性
阶段 7:重新构建并收集填补缺口后的指标
cmake -S test -B build/cov -DENABLE_TEST_COVERAGE=ON -DCMAKE_CXX_COMPILER=g++ -G "Unix Makefiles"
cmake --build build/cov
./build/cov/<TestExecutable>
# 重新生成覆盖率报告
gcov -o CMakeFiles/<Tests>.dir/source/test_<module>.cpp.o <path>/include/<project>/<module>.hpp
gcov -o _deps/<project>-build/CMakeFiles/<Project>.dir/source/<module>.cpp.o <path>/source/<module>.cpp
阶段 8:报告覆盖率改进
| 指标 | 之前 | 之后 | Δ |
|--------|--------|-------|---|
| 测试用例 | N | N' | +Δ |
| 断言 | M | M' | +Δ |
| <module>.hpp | XX% | 100% | +Δ |
| <module>.cpp (编译) | 0% | 100% | +100% |
| 所有其他文件 | ≥90% | ≥90% | 0% |
覆盖率缺口已填补:✅
关键规则
规则 1:覆盖率是事实,而非测试数量
只有当移除一个测试文件后,每个库头文件的行覆盖率保持不变时,该文件才是冗余的。如果覆盖率下降,则说明该文件覆盖了唯一的代码路径。立即回退。
添加测试时,只有当新测试 提高了目标文件的覆盖率 时才算成功。如果目标文件的覆盖率没有增加,则测试未执行代码——请重新检查测试逻辑。
规则 2:检查未覆盖的行,而不仅仅是百分比
两次运行可能都显示“98.06%”,但未覆盖的行不同。始终比较 .gcov 文件输出中 #####: 行的 确切集合:
Get-Content build/cov/foo.hpp.gcov | Select-String -Pattern "^ #####:"
这些必须在去重前后完全相同。对于填补缺口,目标文件的 #####: 行必须减少。
规则 3:不要对基于属性的测试进行去重
基于属性/生成式的测试(例如 RapidCheck)通过设计是故意冗余的。它们用随机输入验证不变量。即使与基于示例的测试重叠,也不应移除它们。
规则 4:不要对不同值的测试进行去重
同一操作符使用不同输入值的两个测试会执行不同的代码路径。仅当输入值和预期输出完全相同时才能移除。
编写新测试时,使用与现有测试 不同的输入值。重复相同的值不会增加覆盖率。
规则 5:始终从干净状态重新构建
.gcda 文件由 gcov 使用,并且在任何源文件或二进制文件更改后变得过时。在测量之前始终重新构建并重新运行:
cmake --build build/cov --clean-first # 或者从头重建
./build/cov/<TestExecutable>
规则 6:检查 CMake GLOB 行为
如果 CMakeLists.txt 使用带有 CONFIGURE_DEPENDS 的 file(GLOB ...),CMake 会在重新构建时自动检测添加/移除的文件(CMake 3.12+)。对于旧版 CMake,在添加或移除测试文件后手动重新配置。
编写新的测试文件时,验证 CMake 是否检测到它:
- 在构建输出中查找
test_<module>.cpp - 如果缺失,重新配置:
cmake -S test -B build/cov
规则 7:硬编码预期值,不要重新计算
编写测试时,永远不要使用相同的被测代码来计算预期值。始终手动推导并将它们硬编码为字面量。这可以捕获回归错误:当实现发生变化时,测试仍然通过,因为两侧都使用了相同的错误代码。
注释复杂的推导:
// tau = |5*1| = 5, bound = 5 - 2/1 = 3
// g>0 so u=3, l=5-5=0
// _r = half_nonnegative(3-0) = 1.5, _xc = 0 + 1.5 = 1.5
CHECK_EQ(e.xc(), 1.5);
规则 8:测试每个分支
对于每个包含条件逻辑的函数,枚举所有分支,并为每个分支编写至少一个测试:
| 分支 | 测试场景 |
|---|---|
if (beta > tau) | 无解路径 |
if (beta < -tau) | 无效果路径 |
else(成功) | 成功路径 |
g > 0 在成功内部 | 正梯度 |
g < 0 在成功内部 | 负梯度 |
边界 beta == tau | 仍然成功 (≤) |
边界 beta == -tau | 取决于 < 还是 ≤ |
规则 9:优先考虑编译的源文件而非头文件
编译后的 .cpp 文件更可能具有零覆盖率,因为仅头文件的模板代码通常会被其他测试偶然执行。优先测试:
- 没有匹配
test_*.cpp的source/*.cpp→ 最高优先级 - 行覆盖率 < 90% 的头文件 → 中等优先级
- 其他已覆盖文件中的未覆盖分支 → 较低优先级
规则 10:匹配项目测试约定
在编写测试之前,研究 2-3 个现有的测试文件以匹配:
…
[由于翻译而截断]