22、CMake进阶:测试与CTest:enable_testing, add_test的使用,CTest的配置与运行,测试覆盖率。

说实话,很多嵌入式工程师对测试的态度是「能跑就行」。我以前也这么想,直到有一次在量产前发现一个定时器中断的边界条件没覆盖到,差点导致整个批次返工。从那以后,我把测试放到了和编译同等重要的位置。

CMake 自带的 CTest 工具,说白了就是帮你把测试这件事「自动化」起来。你不用再手动敲命令去跑每个测试用例,也不用担心漏掉某个模块。咱们今天就把 CTest 的配置、运行和覆盖率讲透。

22.1 开启测试:enable_testing()

在 CMake 里启用测试,只需要在顶层 CMakeLists.txt 里加一行:

enable_testing()

嗯,就这么简单。但有个坑——这行必须写在顶层目录,不能写在子目录里。我刚开始用的时候,习惯性地把它放到了 test/CMakeLists.txt 里,结果 CTest 死活不认。后来查文档才发现,enable_testing() 只在当前目录及其子目录生效,而 CTest 默认从构建目录的顶层开始扫描。

注意:enable_testing() 必须在 add_subdirectory() 之前调用,否则子目录里的测试不会被注册。

22.2 注册测试:add_test()

有了测试框架,接下来就是把具体的测试用例注册进去。基本用法是:

add_test(NAME test_name COMMAND executable [args...])

举个例子,假设你有一个单元测试的可执行文件 test_uart

add_executable(test_uart test_uart.c)
target_link_libraries(test_uart PRIVATE uart_lib)

add_test(NAME uart_basic_test COMMAND test_uart --basic)
add_test(NAME uart_baud_test COMMAND test_uart --baudrate 115200)

这里我习惯把测试分成多个 NAME,每个 NAME 对应一个测试场景。这样当某个测试失败时,CTest 能精确告诉你哪个场景出了问题,而不是笼统地报「test_uart 挂了」。

小技巧:如果测试程序需要传递多个参数,建议用双引号括起来,避免 CMake 的列表展开把你坑了。比如 COMMAND test_uart "--baudrate 115200 --parity even"。

22.3 CTest 的配置与运行

配置好测试后,运行起来就很简单了。在构建目录下执行:

cmake --build .
ctest

CTest 默认会并行运行测试,输出结果类似:

Test project /home/user/build
    Start 1: uart_basic_test
1/2 Test #1: uart_basic_test ...............   Passed  0.12 sec
    Start 2: uart_baud_test
2/2 Test #2: uart_baud_test ...............   Passed  0.08 sec

100% tests passed, 0 tests failed out of 2

我个人常用的几个 CTest 参数:

参数 作用 我的使用场景
-V 显示详细输出 调试测试失败时必加
-N 只列出测试,不运行 检查测试是否注册成功
-R 按正则匹配测试名 只跑某个模块的测试
-j 并行数 嵌入式交叉编译时设 -j1 避免资源冲突

举个例子,我只想跑 UART 相关的测试:

ctest -R uart -V

这样就能看到每个 UART 测试的完整输出了。

22.4 测试覆盖率

覆盖率是个好东西,但别迷信它。我见过有人把覆盖率堆到 95%,结果核心逻辑还是漏了。覆盖率只能告诉你「哪些代码被执行了」,不能告诉你「执行得对不对」。

在 CMake 里集成覆盖率,通常用 gcov 配合 lcov。配置步骤:

  1. 编译时加上 -fprofile-arcs -ftest-coverage--coverage
  2. 运行测试生成 .gcda 文件
  3. lcov 收集数据,生成 HTML 报告

在 CMake 里可以这样设置:

option(ENABLE_COVERAGE "Enable coverage build" OFF)

if(ENABLE_COVERAGE)
    set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fprofile-arcs -ftest-coverage")
    set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} --coverage")
endif()

然后写个自定义目标来生成报告:

add_custom_target(coverage
    COMMAND lcov --capture --directory . --output-file coverage.info
    COMMAND genhtml coverage.info --output-directory coverage_report
    WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
    COMMENT "Generating coverage report..."
)

运行:

cmake -DENABLE_COVERAGE=ON ..
make
ctest
make coverage

打开 coverage_report/index.html 就能看到每个文件的覆盖情况了。

我的经验:嵌入式项目里,硬件相关的代码覆盖率往往很低,因为很多分支依赖外部信号。这时候别硬撑,把硬件抽象层(HAL)的测试做好就行,底层寄存器操作可以单独用模拟器测。

22.5 知识体系总览

下面这张图总结了 CTest 的核心流程和覆盖率集成方式:

CMakeLists.txt enable_testing() add_test(NAME ... COMMAND ...) ctest -V ctest -R uart 测试通过/失败 测试通过/失败 lcov + genhtml

从图里能看出来,CTest 的核心就是三步:开启、注册、运行。覆盖率是锦上添花,不是必须的。我建议你先跑通基础测试,再考虑加覆盖率。

22.6 避坑指南

最后分享几个我踩过的坑:

  • 测试可执行文件找不到:add_test 里的 COMMAND 必须是构建出来的可执行文件路径。如果你用了 add_subdirectory,记得检查目标是否在同一个构建目录下。
  • 交叉编译环境下的测试:CTest 默认在宿主机上运行测试。如果你要测 ARM 目标板,得用 add_test 配合 --test-command 或者自己写个脚本把测试文件传到板子上再跑。
  • 覆盖率文件被覆盖:多次运行测试时,.gcda 文件会累积数据。如果你只想测一次,记得先清理:find . -name "*.gcda" -delete

嗯,CTest 的内容差不多就这些。说白了,它就是个帮你自动跑测试的工具,配置起来不复杂,但用好了能省不少心。下次咱们聊聊更高级的测试框架集成,比如 Google Test 怎么和 CMake 配合。


公众号:蓝海资料掘金营,微信deep3321