第20章:测试——让代码质量有据可依

测试这件事,说实话,我早年做C++项目时并不太重视。总觉得“我写的代码还能有bug?”——结果被打脸无数次。后来在维护一个百万行CMake项目时,每次提交都提心吊胆,生怕改了一处崩了十处。直到我把测试体系完整搭建起来,才真正体会到什么叫“睡得踏实”。

这一章,我们就来聊聊CMake里的测试生态。从最基础的enable_testingadd_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_addtest_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分钟。你想想看,这效率提升有多明显。

小技巧:在CI/CD流水线中,建议使用 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文件。然后用gcovlcov生成可读的报告:

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%以上。低于这个数,说明你的测试还有不少盲区。

核心要点:覆盖率不是万能的。100%的覆盖率不代表没有bug,但0%的覆盖率一定意味着风险。我见过一些项目,覆盖率90%以上,但核心逻辑的边界条件全没测到。所以,覆盖率要结合代码审查一起用。

20.5 本章知识体系

下面这张图,帮你把本章的核心逻辑串起来:

CMake 测试体系结构 CMake 测试体系 enable_testing + add_test CTest 执行器 Google Test 集成 注册测试用例 NAME + COMMAND 并行执行 / 过滤 -j / -R / -V FetchContent 下载 GTest::GTest 链接 冒烟测试 / 回归测试 测试报告输出 单元测试 / 断言 测试覆盖率分析(gcov / lcov)

从这张图能看出来,CMake的测试体系是分层递进的。底层是enable_testingadd_test,中间层是CTest的执行调度,上层是Google Test这样的专业框架。最上面,还有覆盖率工具帮你衡量测试质量。

20.6 避坑指南

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

  • enable_testing 位置不对:一定要在顶层CMakeLists.txt里调用。子目录调用无效。
  • 忘记链接 GTest::Main:如果不自己写main函数,必须链接GTest::Main,否则链接器会报错。
  • 覆盖率数据不更新:如果你改了代码但没重新编译,覆盖率数据还是旧的。记得make clean后再编译。
  • 测试名称重复:同一个CMake项目中,测试名称必须唯一。重复的add_test会覆盖之前的定义。

我曾经在一个项目里,因为测试名称重复,导致一个重要的回归测试被静默覆盖掉了。CI跑了一周都是绿的,直到上线前才发现那个测试根本没执行。从那以后,我养成了一个习惯:每次新增测试,都用ctest -N确认一下测试列表。

好了,这一章的内容就到这里。测试这件事,投入越多,回报越大。别等到线上出bug了才后悔——嗯,我是过来人。