第20章:测试——让代码质量有据可依
测试这件事,说实话,我早年做C++项目时并不太重视。总觉得“我写的代码还能有bug?”——结果被打脸无数次。后来在维护一个百万行CMake项目时,每次提交都提心吊胆,生怕改了一处崩了十处。直到我把测试体系完整搭建起来,才真正体会到什么叫“睡得踏实”。
这一章,我们就来聊聊CMake里的测试生态。从最基础的enable_testing和add_test,到集成Google Test,再到覆盖率分析,一条龙讲清楚。
20.1 测试的起点:enable_testing 与 add_test
在CMake里启用测试,只需要两步。但这两步的顺序,我见过不少人搞反。
先看一个最简单的例子:
cmake_minimum_required(VERSION 3.16)
project(MyApp)
# 启用测试支持
enable_testing()
# 添加一个可执行文件
add_executable(my_app main.cpp)
# 注册测试
add_test(NAME smoke_test COMMAND my_app --version)
这里有个关键点:enable_testing()必须在顶层CMakeLists.txt中调用。如果你在子目录里调用它,CTest根本不会生效。我刚开始用CMake时就踩过这个坑——在子模块里写了enable_testing(),结果ctest命令跑出来永远是“0 tests”。
enable_testing() 只在当前目录及其子目录生效。如果你在子目录中调用,父目录的测试框架不会感知到。所以,请始终在顶层 CMakeLists.txt 中调用它。
add_test的语法也很灵活。你可以指定测试名称、可执行文件、命令行参数,甚至设置超时时间:
add_test(NAME unit_test_1 COMMAND test_runner --suite=math --timeout 30)
我个人习惯给每个测试起一个有意义的名字,比如test_math_add、test_string_trim。这样当测试失败时,CTest的输出一眼就能看出是哪个模块出了问题。
20.2 CTest:测试的执行者
CTest是CMake自带的测试驱动程序。你不需要额外安装,CMake装好它就跟着来了。
运行测试很简单:
mkdir build && cd build
cmake ..
cmake --build .
ctest
CTest会扫描所有通过add_test注册的测试,逐个执行并报告结果。默认输出是这样的:
Test project /home/user/project/build
Start 1: smoke_test
1/1 Test #1: smoke_test ...................... Passed 0.02 sec
100% tests passed, 0 tests failed out of 1
CTest还支持很多实用选项。我常用的几个:
ctest -V:显示详细输出,适合调试ctest -N:只列出测试,不执行ctest -R "math":只运行名称匹配“math”的测试ctest -j4:并行运行4个测试,能大幅缩短总时间
有一次我在一个大型项目里跑测试,单线程要跑40分钟。加上-j8后,直接缩短到6分钟。你想想看,这效率提升有多明显。
ctest --output-on-failure。这样只有失败的测试才会打印详细日志,避免日志被海量信息淹没。
20.3 集成Google Test:专业测试框架
光用add_test跑命令行程序,只能做最简单的冒烟测试。真正要写单元测试,还是得上Google Test这样的框架。
CMake集成Google Test的方式有好几种。我推荐用FetchContent,因为它不需要手动下载源码,也不需要系统安装:
include(FetchContent)
FetchContent_Declare(
googletest
URL https://github.com/google/googletest/archive/release-1.12.1.tar.gz
)
FetchContent_MakeAvailable(googletest)
# 启用测试
enable_testing()
# 添加测试可执行文件
add_executable(test_math test_math.cpp)
target_link_libraries(test_math GTest::GTest GTest::Main)
# 注册到CTest
add_test(NAME test_math COMMAND test_math)
对应的测试代码test_math.cpp长这样:
#include <gtest/gtest.h>
int add(int a, int b) { return a + b; }
TEST(MathTest, AddPositive) {
EXPECT_EQ(add(1, 2), 3);
}
TEST(MathTest, AddNegative) {
EXPECT_EQ(add(-1, -2), -3);
}
int main(int argc, char **argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
这里有个细节:GTest::Main库提供了main函数,你不需要自己写。但如果你需要自定义初始化逻辑,也可以自己写main,然后只链接GTest::GTest。
我曾经在一个项目里发现,测试代码里忘了调用RUN_ALL_TESTS(),结果所有测试都显示“passed”,但实际上一个都没跑。嗯,这种低级错误,犯过一次就再也不会犯了。
20.4 测试覆盖率:看看你的测试到底测了多少
测试覆盖率是个好东西。它能告诉你:哪些代码被测试到了,哪些代码还在“裸奔”。
在CMake里启用覆盖率,通常配合GCC的--coverage选项:
cmake_minimum_required(VERSION 3.16)
project(MyApp)
option(ENABLE_COVERAGE "Enable coverage reporting" OFF)
if(ENABLE_COVERAGE)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} --coverage")
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} --coverage")
endif()
enable_testing()
add_executable(my_app main.cpp)
add_test(NAME smoke_test COMMAND my_app --version)
构建并运行测试后,会生成.gcda和.gcno文件。然后用gcov或lcov生成可读的报告:
mkdir build && cd build
cmake -DENABLE_COVERAGE=ON ..
make
ctest
lcov --capture --directory . --output-file coverage.info
genhtml coverage.info --output-directory coverage_report
打开coverage_report/index.html,就能看到每个文件的覆盖率数据。我个人习惯把覆盖率目标定在80%以上。低于这个数,说明你的测试还有不少盲区。
20.5 本章知识体系
下面这张图,帮你把本章的核心逻辑串起来:
从这张图能看出来,CMake的测试体系是分层递进的。底层是enable_testing和add_test,中间层是CTest的执行调度,上层是Google Test这样的专业框架。最上面,还有覆盖率工具帮你衡量测试质量。
20.6 避坑指南
最后,分享几个我踩过的坑:
- enable_testing 位置不对:一定要在顶层CMakeLists.txt里调用。子目录调用无效。
- 忘记链接 GTest::Main:如果不自己写main函数,必须链接
GTest::Main,否则链接器会报错。 - 覆盖率数据不更新:如果你改了代码但没重新编译,覆盖率数据还是旧的。记得
make clean后再编译。 - 测试名称重复:同一个CMake项目中,测试名称必须唯一。重复的
add_test会覆盖之前的定义。
我曾经在一个项目里,因为测试名称重复,导致一个重要的回归测试被静默覆盖掉了。CI跑了一周都是绿的,直到上线前才发现那个测试根本没执行。从那以后,我养成了一个习惯:每次新增测试,都用ctest -N确认一下测试列表。
好了,这一章的内容就到这里。测试这件事,投入越多,回报越大。别等到线上出bug了才后悔——嗯,我是过来人。