26. 7.2 测试运行与结果分析

导言

在上一节中,我们学习了如何通过 add_test 向项目中添加测试用例,以及如何设置超时、成本、标签和依赖关系等属性。如果把测试用例比作士兵,那么上一节我们完成的是“招兵买马”和“排兵布阵”;而在本节中,我们要学习的是如何“发号施令”——也就是通过 ctest 命令行工具来高效地运行这些测试,并对结果进行精准的分析与展示。

本节内容完全聚焦于测试的执行阶段。你将掌握从本地调试单个失败用例,到并行跑完上千个测试,再到将结果自动提交到 CDash 可视化平台的全套实战技巧。

一、ctest 命令行详解

ctest 是 CMake 内置的测试驱动程序。它不仅仅是一个简单的“运行所有测试”的工具,其命令行选项足以应对从本地开发到 CI 流水线的各种复杂场景。

1.1 基本语法与常用选项速查

在构建目录下(即运行过 cmake --build 的目录),直接输入 ctest 即可执行所有已注册的测试。但更多时候,我们需要精细控制其行为:

# 进入构建目录
cd build

# 最基本:运行所有测试
ctest

# 指定构建类型(对多配置生成器如 Visual Studio、Xcode 尤其重要)
ctest -C Debug

# 只列出测试而不执行(dry-run)
ctest -N

# 显示详细输出(每个测试的标准输出都会打印)
ctest -V

# 显示更详细的输出(包括测试配置信息)
ctest -VV

# 测试失败时立即打印该测试的输出(调试神器)
ctest --output-on-failure

# 只运行指定的测试编号(从 CTestTestfile.cmake 中的序号)
ctest -I 3,5,7

# 运行第 3 到第 10 个测试
ctest -I 3,10

对于日常开发,最推荐的本地调试组合是:

ctest -C Debug --output-on-failure -R "TestName"

这组命令的含义是:在 Debug 配置下,只运行名字匹配 TestName 的测试,如果失败则立即在终端显示该测试的 stdout/stderr,省去我们手动翻日志文件的麻烦。

1.2 退出码与 CI 集成

ctest 的退出码(Exit Code)遵循标准规范:0 表示所有测试通过,非 0 表示有测试失败。这使其可以无缝接入 CI/CD 流水线:

# .github/workflows/ci.yml 片段
- name: Run Tests
  run: ctest --output-on-failure -C Release

二、并行测试执行

当项目中有数百甚至数千个测试用例时,串行执行会成为瓶颈。ctest 内置了强大的并行调度能力,其底层原理与 make -jninja 类似。

2.1 使用 -j 参数

通过 -j 参数指定并行作业数:

# 使用 4 个并行进程运行测试
ctest -j 4

# 自动检测 CPU 核心数并全部用上(慎用,可能耗尽系统资源)
ctest -j$(nproc)  # Linux
ctest -j$(sysctl -n hw.ncpu)  # macOS

需要注意的是,默认情况下 ctest 认为所有测试都可以安全并行。如果某些测试存在资源竞争(例如同时操作同一个数据库、抢占同一个网络端口),你就需要通过属性告诉 ctest 进行协调。

2.2 资源管理:RESOURCE_LOCK 与 PROCESSORS

在 7.1 节中我们提到过 set_tests_properties,这里我们用它来管理并行资源:

场景 A:互斥锁(RESOURCE_LOCK)

假设 test_db_writetest_db_migrate 都会修改同一个本地 SQLite 文件,它们不能同时运行,但各自都可以与其他不碰数据库的测试并行:

add_test(NAME test_db_write COMMAND test_db_write)
add_test(NAME test_db_migrate COMMAND test_db_migrate)
add_test(NAME test_math COMMAND test_math)

set_tests_properties(
    test_db_write test_db_migrate
    PROPERTIES RESOURCE_LOCK "sqlite_db_file"
)

这样,ctest -j 会确保这两个测试不会同时执行,但 test_math 可以与它们中的任一个并行。

场景 B:CPU 核心配额(PROCESSORS)

如果你的某个测试是多线程的,本身就占用了大量 CPU,可以声明它需要的处理器数量:

add_test(NAME heavy_simulation COMMAND heavy_sim)
set_tests_properties(heavy_simulation PROPERTIES PROCESSORS 4)

当运行 ctest -j 8 时,ctest 的调度器会把这个测试当作占用 4 个槽位(slot)的任务,从而最多再并行调度一个占用 4 槽位(或四个占用 1 槽位)的测试,避免系统过载。

2.3 COST 与负载均衡

结合 7.1 节提到的 COST 属性,ctest 会优先启动耗时长的测试,从而最大化并行效率:

set_tests_properties(
    heavy_simulation
    PROPERTIES COST 120  # 预计耗时 120 秒
)

set_tests_properties(
    quick_unit_test
    PROPERTIES COST 1.5  # 预计耗时 1.5 秒
)

在并行模式下,heavy_simulation 会被优先调度,避免它因为启动太晚而成为最后拖慢总耗时的长尾任务。

三、测试筛选

大型项目中,我们通常只想运行特定模块的测试,或排除某些耗时的集成测试。ctest 提供了丰富的筛选机制。

3.1 按名称正则匹配(-R 与 -E)

# 只运行名字包含 "network" 的测试
ctest -R network

# 运行以 "math_" 开头的测试
ctest -R "^math_"

# 排除名字包含 "slow" 的测试(E 即 Exclude)
ctest -E slow

# 组合使用:跑网络测试,但排除慢的
ctest -R network -E slow

3.2 按标签筛选(-L 与 -LE)

如果我们在 CMakeLists.txt 中给测试打好了标签:

add_test(NAME test_serialization COMMAND test_ser)
set_tests_properties(test_serialization PROPERTIES LABELS "io;fast")

add_test(NAME test_tcp_server COMMAND test_tcp)
set_tests_properties(test_tcp_server PROPERTIES LABELS "network;slow")

那么命令行筛选就非常直观:

# 只跑标签为 fast 的测试
ctest -L fast

# 排除标签为 slow 的测试(日常开发常用)
ctest -LE slow

# 多个标签是“或”关系:跑带有 fast 或 critical 标签的测试
ctest -L "fast|critical"

3.3 组合筛选策略

-R-L 可以组合使用,它们之间是“与”关系:

# 名字包含 "test_io" 并且标签为 "fast" 的测试
ctest -R test_io -L fast

四、输出日志

测试失败时,快速定位问题核心在于查看日志。ctest 提供了多个维度的日志控制。

4.1 终端实时输出

# 失败时立即在终端显示该测试的输出(强烈推荐)
ctest --output-on-failure

# 显示所有测试的全部输出(很吵,但调试时有用)
ctest -V

# 超详细模式(包含 ctest 自身的调试信息)
ctest -VV

4.2 日志重定向到文件

默认情况下,ctest 会在构建目录下创建 Testing/Temporary/LastTest.log 保存上次运行的详细结果。但你也可以自定义输出:

# 将 ctest 自身的摘要输出保存
ctest --output-on-failure > ctest_summary.txt 2>&1

# 使用 -O 选项同时输出到终端和文件
ctest -O TestLog.xml --output-on-failure

4.3 测试输出目录结构

在构建目录的 Testing 文件夹下,ctest 会维护一个完整的结果数据库:

  • Testing/Temporary/LastTest.log:最近一次测试的完整日志
  • Testing/Temporary/CTestCostData.txt:测试成本的历史记录(用于优化下次并行调度)
  • Testing//:每次运行的独立目录,包含 Test.xml 等文件

4.4 控制单个测试的输出截断

某些测试(如压力测试)可能会输出海量日志。CMake 3.29+ 支持截断:

set_tests_properties(
    spammy_test
    PROPERTIES OUTPUT_SIZE 65536  # 只保留最后 64KB 输出
)

五、失败重试

在实际工程中,有些测试存在“不稳定性”(Flaky)——它们可能因网络抖动、磁盘 IO 竞争等原因偶发失败。ctest 提供了多种重试策略。

5.1 repeat 模式详解

# 模式 1:重复运行直到失败(用于复现偶现 Bug)
ctest -R test_network --repeat until-fail:100
# 含义:最多重复 100 次,一旦失败就停止。如果 100 次都通过,ctest 返回成功。

# 模式 2:重复运行直到通过(对 Flaky 测试的宽容策略)
ctest -R test_network --repeat until-pass:5
# 含义:如果测试失败,最多重试 5 次,只要有一次通过即算整体通过。

# 模式 3:超时后重试
ctest -R test_network --repeat after-timeout:3
# 含义:只有测试因 TIMEOUT 失败时才重试,最多 3 次。

注意: until-pass 虽然能绿 CI,但过度使用会掩盖真正的 Bug。建议仅对经过严格审查确认是环境问题的测试使用,并配合日志记录每次失败的原因。

5.2 在 CMakeLists.txt 中声明重试(CMake 3.17+)

你也可以在测试定义时就指定重试次数,无需在命令行记忆:

add_test(NAME flaky_network_test COMMAND flaky_network_test)
set_tests_properties(flaky_network_test PROPERTIES WILL_FAIL FALSE)
set_property(TEST flaky_network_test PROPERTY REPEAT_UNTIL_PASS 3)

这样每次运行 ctest 时,该测试会自动获得 3 次重试机会。

六、CDash 集成

在个人开发和 CI 环境中,测试结果往往分散在各处。CDash 是 Kitware 官方提供的开源测试仪表盘,可以将多平台、多配置的测试结果汇总到 Web 页面,进行趋势分析和可视化展示。

6.1 配置 CTestConfig.cmake

在项目根目录(与顶层 CMakeLists.txt 同级)创建 CTestConfig.cmake

set(CTEST_PROJECT_NAME "MyAwesomeProject")
set(CTEST_NIGHTLY_START_TIME "01:00:00 UTC")
set(CTEST_DROP_METHOD "http")
set(CTEST_DROP_SITE "my.cdash.org")
set(CTEST_DROP_LOCATION "/submit.php?project=MyAwesomeProject")
set(CTEST_DROP_SITE_CDASH TRUE)

其中 my.cdash.org 可以替换为你团队部署的私有 CDash 服务器地址。

6.2 测试与提交的完整工作流

CDash 通过 ctest -Tctest -D 来驱动。一个典型的“实验性”提交流程如下:

cd build

# 方式 1:分步执行(适合理解原理)
ctest -T Start      # 通知 CDash:我要开始一轮新测试
ctest -T Configure  # 提交 CMake 配置信息
ctest -T Build      # 提交构建结果
ctest -T Test       # 运行测试并收集结果
ctest -T Submit     # 将所有结果打包上传到 CDash

# 方式 2:一键执行(日常最常用)
ctest -D Experimental

-D Experimental 等价于依次执行 Start、Update(空操作)、Configure、Build、Test、Submit 六个步骤。

6.3 自定义提交模型

CDash 支持多种预定义模型,用于区分测试的用途:

  • Experimental:开发者本地随意提交,不期望代码是稳定的。
  • Nightly:夜间定时构建,通常拉取主线最新代码完整跑一遍。
  • Continuous:持续集成,代码提交后自动触发。
# Nightly 模式
ctest -D Nightly

# Continuous 模式
ctest -D Continuous

6.4 在 CI 中集成 CDash

以 GitHub Actions 为例,你可以在构建完成后增加提交步骤:

- name: Build and Test with CDash
  run: |
    cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
    cmake --build build --parallel
    cd build
    ctest -D Experimental --output-on-failure
  env:
    CTEST_TOKEN: ${{ secrets.CDASH_TOKEN }}

如果 CDash 服务器需要认证,可以在 CTestConfig.cmake 中配置 CTEST_DROP_SITE_USERCTEST_DROP_SITE_PASSWORD,或者通过环境变量注入 Token。

6.5 查看结果

提交成功后,打开 CDash 项目页面,你可以看到:

  • 每次提交的通过/失败概览
  • 单个测试的历史趋势(最近 7 天是否稳定)
  • 失败的测试输出和标准错误
  • 代码覆盖率随时间变化曲线(如果集成了 GCov)

小结

本节我们完成了从“写测试”到“跑测试”再到“看测试”的闭环:

  1. 掌握了 ctest 命令行的核心选项,尤其是 --output-on-failure 和配置选择 -C
  2. 学会了通过 -j 加速测试,并利用 RESOURCE_LOCKPROCESSORS 避免资源冲突;
  3. 能够灵活运用 -R-L 及其排除参数对海量测试进行精准筛选;
  4. 理解了 ctest 的日志机制和输出控制;
  5. 了解了针对不稳定测试的 --repeat 重试策略;
  6. 初步掌握了将测试结果提交到 CDash 进行团队级可视化分析的方法。

在下一节中,我们将继续深入质量保障领域,探讨如何利用 CMake 集成代码覆盖率(Code Coverage)工具,让你的测试不仅能“跑通”,还能“盖全”。

请登录后发表评论

    没有回复内容

正在唤醒异次元光景……