16、CMake中的测试支持:enable_testing()、add_test()、ctest命令、测试用例编写与运行
测试这件事,说实话,很多C++项目一开始都不太重视。我早年带团队时也踩过这个坑——代码越写越多,改一个底层模块,上层全崩了,全靠肉眼排查。后来我强制在CMake里把测试框架搭起来,才算是把命救回来了。
CMake对测试的支持其实很轻量,它不规定你用哪个测试框架,也不限制你写测试的方式。它只做三件事:启用测试、注册测试、运行测试。说白了,就是给你一个统一的入口,把测试这件事管起来。
16.1 启用测试:enable_testing()
想在项目里用CMake的测试功能,第一步就是调用 enable_testing()。这个命令必须在顶级CMakeLists.txt里调用,而且要在 add_subdirectory() 之前。为什么?因为测试是全局的,子目录里的测试用例需要注册到顶层。
关键点: enable_testing() 只能在顶级CMakeLists.txt中调用一次。如果你在子目录里调用,CMake会直接报错。
我个人习惯在项目一开始就把这行加上,哪怕当时还没写测试。你想想看,后面加测试只是加几行 add_test() 的事,但要是忘了开这个开关,所有测试注册都会静默失效,排查起来很头疼。
# 顶级 CMakeLists.txt
cmake_minimum_required(VERSION 3.10)
project(MyProject VERSION 1.0.0)
enable_testing() # 开启测试支持
add_subdirectory(src)
add_subdirectory(tests)
16.2 注册测试:add_test()
测试启用之后,怎么告诉CMake「这个可执行文件是一个测试用例」?用 add_test()。
基本语法很简单:
add_test(NAME <test_name> COMMAND <command> [<arg>...])
举个例子,我写了一个单元测试的可执行文件叫 test_math,那么注册方式就是:
add_executable(test_math test_math.cpp)
add_test(NAME MathTest COMMAND test_math)
这里要注意,COMMAND 后面跟的是可执行文件的名字。如果这个可执行文件不在当前构建目录下,你需要指定完整路径,或者用 $<TARGET_FILE:test_math> 这种生成器表达式。
小技巧: 我习惯把测试可执行文件统一放在 tests/ 目录下,然后用 add_subdirectory(tests) 引入。这样顶层CMakeLists.txt很干净,测试的构建和注册逻辑都集中在 tests 目录里。
16.3 测试用例的编写
CMake本身不写测试,它只负责跑。测试用例怎么写,完全取决于你用什么测试框架。我见过团队用Google Test、Catch2、Boost.Test,甚至有人直接用 assert() 加 main() 返回值来搞。
我个人最常用的是Google Test,因为它生态好、断言丰富。但这里我不展开讲某个框架的用法,而是说一个通用的原则:测试可执行文件应该返回0表示通过,非0表示失败。CMake和ctest就是靠这个返回值来判断测试结果的。
举个最简单的例子,不用任何框架:
// test_simple.cpp
#include <cassert>
int add(int a, int b) { return a + b; }
int main() {
assert(add(2, 3) == 5);
assert(add(-1, 1) == 0);
return 0; // 全部通过
}
然后注册:
add_executable(test_simple test_simple.cpp)
add_test(NAME SimpleTest COMMAND test_simple)
嗯,这里要注意:如果 assert 失败,程序会 abort,返回值是非0,ctest就会判定为失败。简单粗暴,但够用。
16.4 运行测试:ctest命令
测试注册好了,怎么跑?用 ctest 命令。它会在构建目录下读取 CTestTestfile.cmake(这个文件是CMake在配置阶段自动生成的),然后依次执行所有注册的测试。
基本用法:
# 先构建
cmake -B build
cmake --build build
# 进入构建目录运行测试
cd build
ctest
# 或者直接指定构建目录
ctest --test-dir build
输出大概长这样:
Test project /path/to/build
Start 1: MathTest
1/2 Test #1: MathTest ........................ Passed 0.01 sec
Start 2: SimpleTest
2/2 Test #2: SimpleTest ...................... Passed 0.00 sec
100% tests passed, 0 tests failed out of 2
看到那个 100% tests passed 的时候,心里还是挺踏实的。
16.5 常用ctest选项
ctest 的选项不少,我挑几个最常用的说说:
| 选项 | 作用 | 示例 |
|---|---|---|
-R |
按正则表达式过滤测试名 | ctest -R Math 只跑名字带Math的测试 |
-E |
排除匹配的测试 | ctest -E Slow 跳过名字带Slow的测试 |
-N |
只列出测试,不运行 | ctest -N 查看所有注册的测试 |
-V |
详细输出 | ctest -V 显示每个测试的完整输出 |
-j |
并行运行测试 | ctest -j4 同时跑4个测试 |
--output-on-failure |
只在失败时输出详细信息 | ctest --output-on-failure |
避坑指南: 我曾经在CI里用 ctest -j 并行跑测试,结果有些测试共享了同一个临时文件,导致随机失败。排查了半天才发现是并行冲突。如果你的测试用例之间有资源竞争,记得加锁或者用 --test-load 限制并行数。
16.6 测试的超时与资源限制
有些测试可能会卡住,比如网络请求超时、死循环等。ctest 提供了超时机制:
add_test(NAME NetworkTest COMMAND test_network)
set_tests_properties(NetworkTest PROPERTIES TIMEOUT 10)
这样如果 test_network 10秒内没跑完,ctest 就会杀掉它并标记为失败。我在做嵌入式交叉编译测试时经常用这个,因为目标板响应慢,不加超时的话CI会卡死。
其他常用属性还有:
COST:测试的成本(用于调度,值越大越晚执行)REQUIRED_FILES:测试需要的文件列表ENVIRONMENT:设置环境变量WORKING_DIRECTORY:设置工作目录
16.7 知识体系总览
下面这张图把CMake测试的整个流程串起来了,从启用到注册再到运行,一目了然:
16.8 实际项目中的测试组织
最后聊聊我在真实项目里怎么组织测试的。一个中型C++项目,测试文件可能几十上百个,全堆在一个目录里肯定不行。
我一般这样搞:
project/
├── CMakeLists.txt # enable_testing() 在这里
├── src/ # 源码
├── tests/
│ ├── CMakeLists.txt # 所有测试的构建和注册
│ ├── unit/ # 单元测试
│ │ ├── test_math.cpp
│ │ └── test_string.cpp
│ ├── integration/ # 集成测试
│ │ └── test_api.cpp
│ └── benchmark/ # 性能测试(可选)
│ └── test_sort.cpp
然后在 tests/CMakeLists.txt 里:
# 单元测试
add_subdirectory(unit)
# 集成测试
add_subdirectory(integration)
# 性能测试(通过选项控制)
option(ENABLE_BENCHMARK "Build benchmark tests" OFF)
if(ENABLE_BENCHMARK)
add_subdirectory(benchmark)
endif()
每个子目录里的 CMakeLists.txt 负责构建和注册该模块的测试。这样层次清晰,新增测试时只需要加文件、改对应子目录的CMakeLists.txt,不会影响到其他地方。
个人经验: 我习惯给每个测试用例起一个有意义的名称,比如 MathTest.AddPositive、StringTest.TrimWhitespace。这样用 ctest -R 过滤时特别方便,而且CI报告里一眼就能看出哪个模块出了问题。
好了,CMake的测试支持就聊到这里。从 enable_testing() 到 add_test(),再到 ctest 的运行与过滤,这一套组合拳打下来,项目的质量就有了最基本的保障。别等到代码炸了才想起写测试——嗯,这话是我用几次通宵加班换来的教训。
公众号:蓝海资料掘金营,微信deep3321