单元测试设计与持续集成实践
GoogleTest 的最终目标是服务工程质量。一个项目是否真正用好了单元测试,要看它能否在本地和 CI 中快速发现回归,并让开发者愿意持续维护。
一个好的单元测试
| 特征 | 说明 |
|---|---|
| 快速 | 本地频繁运行不造成负担 |
| 稳定 | 不依赖测试顺序、系统时间、网络波动 |
| 聚焦 | 一个测试验证一个主要行为 |
| 可读 | 失败后从名字和断言能看懂问题 |
| 可维护 | 不因为内部实现重构而大量失败 |
Arrange Act Assert
TEST(PasswordPolicyTest, RejectsPasswordShorterThanMinimumLength) {
PasswordPolicy policy{/*minimum_length=*/8};
const bool accepted = policy.Accept("abc");
EXPECT_FALSE(accepted);
}
这个结构让测试具有明确节奏:先准备,再执行,最后断言。
测试边界
对一个函数或类,至少考虑以下输入:
- 最小值、最大值、空值。
- 正常路径和错误路径。
- 重复调用后的状态变化。
- 资源不足或依赖失败。
- 历史 bug 对应的回归样例。
避免脆弱测试
脆弱测试通常过度绑定实现细节。例如测试一个排序函数时,应该验证输出有序且元素集合不变,而不是验证内部使用了快速排序还是归并排序。
TEST(SortTest, SortsValuesAscending) {
std::vector<int> values = {3, 1, 2};
SortAscending(values);
EXPECT_THAT(values, ::testing::ElementsAre(1, 2, 3));
}
处理外部依赖
| 依赖 | 建议 |
|---|---|
| 时间 | 注入 Clock 接口或传入时间点 |
| 随机数 | 注入随机数生成器或固定种子 |
| 文件系统 | 使用临时目录,测试后清理 |
| 网络 | 使用本地假服务或接口替身 |
| 数据库 | 使用测试库、事务回滚或内存实现 |
CI 中的测试步骤
拉取代码
-> 配置 CMake
-> 构建
-> 运行 CTest
-> 生成覆盖率
-> 上传测试报告和覆盖率报告
示例命令:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build --parallel
ctest --test-dir build --output-on-failure
覆盖率构建建议独立:
cmake -S . -B build-coverage -DCMAKE_BUILD_TYPE=Debug -DENABLE_COVERAGE=ON
cmake --build build-coverage --parallel
ctest --test-dir build-coverage --output-on-failure
gcovr -r . build-coverage --xml-pretty -o coverage.xml --print-summary
覆盖率门禁
覆盖率门禁适合防止测试大幅退化,但阈值要循序渐进。
gcovr -r . build-coverage \
--fail-under-line 80 \
--fail-under-branch 70
不要一开始就给历史项目设置过高阈值。更务实的做法是先记录基线,再要求新增代码不降低覆盖率。
测试审查清单
- 测试名是否表达行为和条件。
- 失败信息是否足够定位问题。
- 是否覆盖边界值和错误路径。
- 是否存在测试顺序依赖。
- 是否访问了真实外部环境。
- 是否把实现细节写死在断言里。
- 是否能被 CTest 或 CI 自动运行。
何时不适合单元测试
不是所有验证都应该塞进 GoogleTest 单元测试。
| 场景 | 更合适方式 |
|---|---|
| UI 交互 | UI 自动化或端到端测试 |
| 性能基准 | Benchmark 工具 |
| 编译期接口约束 | static_assert 或编译测试 |
| 大规模系统联调 | 集成测试环境 |
| 内存错误检测 | ASan、UBSan、Valgrind 等工具 |
总结
GoogleTest 是测试表达工具,工程质量来自良好的测试边界、稳定的依赖控制、清晰的构建入口和持续运行的反馈机制。先让测试可靠,再追求覆盖率;先让失败可定位,再追求测试数量。