最近在维护一个 C++ 项目时,我把欠了几年的“测试债”补了一部分,过程比预想中更折腾,但也比预想中更能看到回报。这个项目是典型的业务型后端服务,编译产物是一个长期运行的守护进程,代码规模不大不小,十万行上下,模块之间却有历史遗留的耦合:全局单例、静态回调、直接操作裸指针的地方不少。以前每次发版本全靠集成测试和人工回归,跑一轮环境要半小时,出了问题定位又费半天。后来我下决心把核心模块拆出来补单元测试,几周下来,整个写代码的习惯都变了。
这篇文章不是教科书式的单元测试理论,而是我在实际 C++ 项目里落地单元测试时踩过的坑、用过的方案,以及事后觉得值得坚持的实践。适合正在带 C++ 项目的朋友,也适合刚想给老模块补测试、但不知道从哪里入手的新手。文章会从为什么需要补测试讲起,一直到怎么在 VSCode 里跑起来、怎么写高质量的用例、怎么接入 CI 和覆盖率,最后把我遇到过的一批典型问题列成排查实录。读完可以直接照着自己项目搭一套。
1. 为什么这个项目需要补上单元测试
1.1 项目现状与痛点
先说项目背景。这是一个二次开发了很久的业务系统,底层使用 C++ STL 容器做数据处理,上面跑着定时任务、网络回调、插件调度。模块划分表面上清楚,实际编译依赖却很乱:为了省事,不少逻辑被直接塞进类的公有方法里,一个函数动不动就是几十个分支,输入输出都耦合着全局状态。想单独验证某一个功能,得把整个服务拉起来,还必须在特定时序下才能踩到那条路径。
这种项目的通病是:代码可以运行,却很难被信任。加一个新字段,可能影响五个模块的行为;改一个底层工具函数,第二天测试环境才报异常。没人敢碰历史代码,因为改坏了没有快速反馈手段。这时候单元测试的价值就显现出来了——它能把“运行是否正常”这种模糊表述,变成一组可重复、可定位、可自动执行的具体断言。每次改动后跑一遍测试,等于在几秒钟内做了一次全量回归,这比任何评审意见都直接。
还有一个容易被忽略的痛点:新人上手成本高。我刚接手时看代码,想知道一个解析函数在不同输入下的表现,只能靠读代码加推理,或者写临时 main 函数打日志。而有了单元测试之后,ParserTest里清清楚楚摆着十几种输入样例和期望结果,新人看测试比看文档快得多。测试其实就是最贴近代码的可执行文档,它记录了函数作者当时对“正确行为”的全部理解。
1.2 测试方案选型:我为什么选了 GoogleTest
C++ 可用的单元测试框架不少,常见的有 GoogleTest、Catch2、doctest、Boost.Test 等。我最后选了 GoogleTest,理由很实际:团队现有环境统一,CMake 已经是事实标准,GoogleTest 的文档最全,遇到问题时的搜索成本最低。而且它自带测试发现机制,配合 CTest 能在 CI 上一键跑完所有测试,不需要额外写脚本解析输出。
Catch2 和 doctest 都是很优秀的轻量级选择,尤其是 doctest,编译速度非常快,适合那种编译一次半小时的大工程。如果你的项目要用在嵌入式交叉编译环境,或者团队特别反感引入大依赖,Catch2 单头文件版和 doctest 会更友好。但它们的 Mock 能力相对弱,后续要模拟网络、文件系统这类外部依赖时,往往还得自己造轮子。GoogleTest 的 gmock 虽然写起来琐碎,但好歹是完整方案。
如果你是完全新手,我建议不要纠结框架选型。先选 GoogleTest 跑通,把“能测试”的流程建立起来,以后再换框架的成本远低于一开始犹豫的成本。选型最怕的不是选错,而是因为反复比较而迟迟不落地。
2. 在 VSCode + CMake 里把测试跑起来
2.1 从零搭建可测试的目录结构
我们项目原本是一个巨大的可执行文件,所有源码堆在一起。补单元测试的第一个动作不是写测试,而是调整结构,让被测代码和测试代码分家。我把它改成了下面这种常见布局:
project_root/ CMakeLists.txt src/ CMakeLists.txt data_parser.h data_parser.cpp task_queue.h task_queue.cpp math_utils.h math_utils.cpp main.cpp tests/ CMakeLists.txt test_math_utils.cpp test_data_parser.cpp test_task_queue.cpp关键思路:业务代码编译成一个静态库,叫core_lib,main 函数单独编译成可执行文件,测试代码也链接core_lib。这样做的目的是让测试不需要启动整个服务,而是直接对库中的类和方法发起调用。如果代码里还有全局状态的依赖,这个阶段会暴露得很明显——编译不通过、构造函数副作用、静态变量初始化顺序,这些都可能跳出来。不要怕,这正是补测试的前置清理。
对于历史项目,我强烈建议先选出最核心、测试收益最大的部分,比如字符串解析、数值计算、协议编解码,把它们先隔离出来,而不是幻想一次性把所有代码都纳入测试。我见过一个团队试图在两周内给全部十万行业务代码补完测试,结果是所有人都在处理环境依赖和编译问题,用例写得很浅,最后覆盖率也难看。逐步扩大测试范围,阻力会小很多。
2.2 编写 CMakeLists 并接入 gtest
根目录的 CMakeLists 很简单,只列核心部分:
cmake_minimum_required(VERSION 3.14) project(CppUnitTestDemo CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) enable_testing() add_subdirectory(src) if(BUILD_TESTING) add_subdirectory(tests) endif()src 下的 CMakeLists 把业务源码编成静态库,再加一个 main 可执行文件:
add_library(core_lib STATIC data_parser.cpp task_queue.cpp math_utils.cpp ) target_include_directories(core_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) add_executable(app main.cpp) target_link_libraries(app PRIVATE core_lib)测试目录的 CMakeLists 负责拉取并构建 GoogleTest:
include(FetchContent) FetchContent_Declare( googletest GIT_REPOSITORY https://github.com/google/googletest.git GIT_TAG v1.14.0 ) FetchContent_MakeAvailable(googletest) add_executable(test_core test_math_utils.cpp test_data_parser.cpp test_task_queue.cpp ) target_link_libraries(test_core PRIVATE core_lib gtest_main gmock_main ) include(GoogleTest) gtest_discover_tests(test_core)这里有几个细节值得注意。
第一,我用gtest_main而不是自己写 main,这样每个测试文件只需要写 TEST 宏,自动注册和运行都由框架完成。如果你需要自定义全局初始化,可以换成gtest_main之外的方式,但别同时链接多个 main 入口,否则必然链接冲突。
第二,gtest_discover_tests会在构建后扫描可执行文件里的测试用例,逐个注册到 CTest。这样在终端用ctest就能按用例名筛选、输出详细的失败信息,也比传统add_test更省维护成本。我最初用的是add_test(test_core test_core),只能看到一个“通/不通”的结果,排查问题还是得手动执行可执行文件。
第三,如果公司内网无法访问 GitHub,可以把 googletest 源码放到内部代码仓库,把 URL 换成内部地址,或者干脆用git clone之后以源码目录引入。FetchContent_Declare支持本地路径,只是不同项目间复用时需要维护好路径变量。
2.3 VSCode 下调试与一键运行
团队里不少人用 VSCode 写 C++,顺便说一下怎么把测试流程串起来。先安装 C/C++ 插件和 CMake Tools 插件,用 CMake Tools 把项目根目录加载进来,选择对应编译器套件,比如本地已安装的 MSVC 或 MinGW。随后在终端执行三条命令:
cmake -B build -DBUILD_TESTING=ON cmake --build build -j4 ctest --test-dir build --output-on-failure想在 VSCode 里直接调试单个测试,可以在.vscode/launch.json里配置一个针对test_core可执行文件的启动配置,把 program 路径指向构建产物。调试时打断点、查变量,比打日志高效得多。
这里要为刚接触 C++ 的新手多说一句:如果你连 CMake 都还没用过,建议先新建一个最简单的 hello 项目,把cmake -B和cmake --build这两个命令跑熟,再来看测试工程。跳跃式的学习容易让人卡在“为什么我的测试文件找不到头文件”这类基本问题上,其实多数是编译目标没有正确链接库。
3. 测试用例设计:从“能跑”到“能防回归”
3.1 命名、断言与 Arrange-Act-Assert
很多人刚开始写测试时,用例名随便起,或者在一个 TEST 里塞上五六个断言,结果测试失败根本不知道是哪个行为违背了预期。我后来给自己定了三条规范。
测试函数命名要能表达三件事:被测对象、输入场景、预期结果。比如给一个计算面积的函数写测试,我会写:
TEST(MathUtilsTest, CalculateArea_NegativeRadius_ReturnsZero) { double result = CalculateArea(-1.0); EXPECT_EQ(result, 0.0); }这样测试失败时,光看名字就能知道是“负半径被当作合法值”还是“返回值类型错误”。测试内部尽量遵循 Arrange-Act-Assert 三段式:先把输入和依赖准备好,然后执行被测逻辑,最后断言结果。大多数可读性差的测试,问题都出在第三步混进了奇怪的业务判断。
断言部分要分清EXPECT_和ASSERT_系列。EXPECT_EQ失败后,当前用例还会继续往下执行,适合连续检查多个不相关的性质;ASSERT_EQ失败后直接终止当前用例,适合前置条件检查。举个例子,如果你解析一个结构体链表,先要拿到链表头指针,再用ASSERT_NE(ptr, nullptr),下面才能放心用这个指针去访问字段。如果这里用了EXPECT_NE,后面大概率会因为空指针访问而崩溃,排查时反而更绕。
浮点数比较还有一个经典误区:不要用EXPECT_EQ去比较两个浮点数。计算路径稍有不同,浮点误差就会让结果差几个 ULP。应该用EXPECT_NEAR(expected, actual, delta),误差范围根据业务精度要求来定。我在数值计算模块里一般给1e-6,如果业务要求高精度再收紧。
3.2 用测试夹具消除重复代码
当多个测试用例需要准备相同的数据、对象和清理逻辑时,就该上测试夹具了。GoogleTest 的TEST_F宏配合继承::testing::Test的类,可以把SetUp和TearDown公共化。
我举个数据解析的例子。业务里有个函数按行解析配置文本,生成一个由结构体结点组成链表,每个节点里存 key、value 和 next 指针。每个测试用例都要准备一段标准输入,并清理它产生的动态内存。于是我把这些写进夹具:
class DataParserTest : public ::testing::Test { protected: void SetUp() override { parser = std::make_unique<DataParser>(); input_text = "key1=value1\nkey2=value2\n"; } void TearDown() override { parser.reset(); } std::unique_ptr<DataParser> parser; std::string input_text; };然后每个用例只写自己想验证的行为:
TEST_F(DataParserTest, Parse_TwoLines_ReturnsTwoNodes) { ConfigNode* head = parser->Parse(input_text); ASSERT_NE(head, nullptr); EXPECT_EQ(head->key, "key1"); EXPECT_EQ(head->next->key, "key2"); parser->FreeList(head); }这样写带来的另一个好处是,如果后来发现解析前的输入需要做规范化处理,只需改SetUp一处,所有相关用例都会拿到新的前置条件,不会因为漏改某个用例导致测试集自相矛盾。
3.3 参数化测试覆盖边界条件
很多 C++ 新手不知道 GoogleTest 还有参数化测试机制。简单说,TEST_P配INSTANTIATE_TEST_SUITE_P可以让你用同一份测试逻辑跑多组数据。这件事在验证边界条件时极其有用。
比如MathUtils里的一个判断质数的函数,我原本写了五六个 TEST,逻辑一模一样,只是输入和期望不同。改成参数化之后清爽多了:
class PrimeTest : public ::testing::TestWithParam<std::tuple<int, bool>> {}; TEST_P(PrimeTest, IsPrime_ChecksExpectedResult) { int input = std::get<0>(GetParam()); bool expected = std::get<1>(GetParam()); EXPECT_EQ(IsPrime(input), expected); } INSTANTIATE_TEST_SUITE_P( PrimeTestCases, PrimeTest, ::testing::Values( std::make_tuple(1, false), std::make_tuple(2, true), std::make_tuple(3, true), std::make_tuple(4, false), std::make_tuple(97, true), std::make_tuple(100, false) ));我建议每个被测函数的参数化数据里,必须包含正常值、边界值、非法值这三类。正常值确认主流程,边界值专门找 off-by-one 错误,非法值验证防御逻辑。往往后两类才是线上真正出问题的地方。参数化还有个隐藏好处:新增一组边界数据时,只用往Values里加一行,不需要复制粘贴整个测试函数。测试集维护成本因此大幅下降。
3.4 回调、随机数与时间相关测试的稳定性
项目里有很多基于回调的异步逻辑,这类代码天然难测。常见的不稳定因素有三个:真实随机数、真实时间等待、外部 IO。我给自己的原则是,测试里永远不要依赖“靠运气”的结果。
以任务队列为例,它接收std::function<void()>类型的回调函数。测试时我用一个计数器模拟回调的执行次数,并且确认被测代码的 FIFO 顺序:
TEST(TaskQueueTest, RunOne_ExecutesCallbackInFifoOrder) { std::vector<int> log; TaskQueue queue; queue.Post([&]() { log.push_back(1); }); queue.Post([&]() { log.push_back(2); }); queue.RunOne(); EXPECT_EQ(log[0], 1); queue.RunOne(); EXPECT_EQ(log[1], 2); }涉及随机数的模块,我强烈建议不要让测试直接调用std::rand或std::random_device。把随机数生成器设计成可注入对象,测试时固定种子,或者完全换成固定数据,这样同样的测试才能稳定复现。否则你昨天跑通过、今天跑失败,排查起来很想骂人。
涉及等待时长的测试,更要小心。尽量缩短等待区间,或者用条件变量配合超时,让测试在资源允许时尽快继续,而不是无脑sleep(3)。我见过一个最经典的脆测案例:sleep(1)等待后台线程完成,结果 CI 机器负载高时线程没跑完,测试偶发失败,治理成本非常高。最后我们改成事件通知加超时断言,问题才根治。
4. 接入 CI 与代码覆盖率:让测试持续产生价值
4.1 GitHub Actions 中的跨平台测试矩阵
如果测试只在本地跑,价值要打五折。我把它接进了 GitHub Actions,主要做两件事:每次提交跑一遍完整测试,并生成覆盖率报告。下面是一份精简的 CI 配置,先把最小闭环跑通:
name: CI on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Configure run: cmake -B build -DBUILD_TESTING=ON -DCMAKE_BUILD_TYPE=Debug - name: Build run: cmake --build build -j2 - name: Run tests run: ctest --test-dir build --output-on-failure这份配置能做的就是:有人在仓库里推送代码或提 Pull Request 时,自动编译并跑完全部测试。任何用例失败,PR 就会被红色标记,负责人在合入前就会看到排查重点。我见过不少团队明明写了测试,却因为不接 CI 而形同虚设,最后还得靠人肉催“你跑一下测试没”,这种做法对测试文化的伤害比没有测试还大。
如果要覆盖 Windows 平台,运行器换成windows-latest,此时会默认使用 MSVC 编译器。需要注意一点:Windows 环境跑 gtest 相关的测试可执行文件,目标机器上必须装有对应的 Microsoft Visual C++ Redistributable 运行库。本地开发机通常默认具备,但干净 CI 镜像和 Docker 容器里未必如此。如果报错提示缺少 dll,安装对应版本的 redistributable 即可;在 GitHub Actions 的标准 Windows 镜像里,这个问题多半已经处理过了,但自己组的构建机一定要检查。
做跨平台矩阵时,我比较推荐用三个 job 或一个矩阵:ubuntu-latest、windows-latest、macos-latest。每个平台能暴露一类特有环境问题,比如文件路径分隔符、链接库命名方式、运行时库不同导致的行为差异。对商业项目来说,这点成本不算高,换来的是对未来一段时间未知环境问题的安心感。
4.2 gcov/lcov 覆盖率统计与改进实践
覆盖率工具在 Linux 上最顺手的组合是 gcov + lcov。前提是编译时打开 coverage 选项,我用 CMake 变量来控制:
cmake -B build -DCMAKE_CXX_FLAGS="--coverage" -DBUILD_TESTING=ON cmake --build build -j4 ctest --test-dir build --output-on-failure lcov --capture --directory build --output-file coverage.info lcov --remove coverage.info '*/tests/*' '/usr/*' '*/extern/*' \ --output-file coverage_filtered.info genhtml coverage_filtered.info --output-directory coverage_report生成的coverage_report/index.html可以在浏览器里直接看。我最看重的不只是总覆盖率这个数字,而是“哪些行没被执行到”。因为总覆盖率在小型项目里很容易到 90% 以上,而真正危险的往往是某些错误处理分支完全没有被踩过。每次看报告,我会优先给未覆盖分支补测试,把覆盖率当作地图使用,而不是当作 KPI 赶工。
在向管理汇报时,我也会把“测试覆盖了哪几个关键模块”“还有哪几条 return 路径没有被自动验证”讲清楚,这比一个百分比更有说服力。如果在评审会上只丢出“覆盖率 95%”这句话,没人知道被测的是核心逻辑还是把配置文件解析也硬凑进去了。覆盖率数字只有在配合模块维度的表格时才有意义。
如果你用的是 Windows + MSVC,lcov 那套不太适用。可以试试 Visual Studio 自带的代码覆盖率功能,或者使用 OpenCppCoverage 这类第三方工具。本质上思路一样:不管用什么工具,目的都是发现那些没有被测试触碰到的死角和异常路径。
5. 常见问题与排查实录
5.1 链接错误与 main 冲突
我第一次搭建测试工程时,立刻就踩到了重复定义错误。原因是测试可执行文件同时链接了项目自己的 main 函数对象文件和 gtest 的gtest_main。解决方案很简单:只保留一个 main。如果你需要自己写 main,就只链接gtest而不链接gtest_main;如果不需要特殊初始化,直接用gtest_main,把main.cpp从被测库中剥离出去。
还有一个常见的链接错误是找不到testing::Test系列符号,多数是因为所有测试代码所在的库没有正确链接 gtest。检查一下target_link_libraries和target_link_directories是不是配错了。这类问题只要冷静下来,先从最简单的两个文件搭起,再增量的方式引入,就很容易定位。
5.2 测试进程崩溃与内存问题
测试里发生了段错误,不要慌,先区分问题出在哪一层。如果连测试可执行文件都起不来,可能是启动阶段就崩溃,常见原因是全局对象的构造依赖了某个未初始化资源。如果运行到某个用例时崩溃,用 GoogleTest 的--gtest_filter=TestSuiteName.TestName参数单独复现该用例,极大缩小范围。配合 gdb 或者 ASan 使用,一条命令就能看到调用栈:
./build/tests/test_core --gtest_filter=TaskQueueTest.*如果项目允许,我强烈建议在开发配置里开启 AddressSanitizer,比如给编译器加上-fsanitize=address -g。它能自动揭露越界、释放后使用、内存泄漏等问题。单元测试加 ASan,是抓 C++ 内存问题的最强组合之一。代价是运行变慢,所以建议用于单独的测试编译配置,不要放在普通发布构建里。
5.3 离线/旧环境编译 gtest 的坑
在公司内网或旧操作系统上编译新版本 gtest,可能会遇到各种编译错误。原因往往是新版本 gtest 对编译器版本有要求。比如 Ubuntu 16.04 默认的 GCC 5 编译 gtest 1.14 就会报 C++17 特性支持不足。破解方法有两个:升级编译器,或者选用与工具链匹配的旧版 gtest。旧项目如果还维持很老的构建环境,我不建议为了“用最新版”硬上,能稳定编译本身就是优先级更高的事。
离线环境下,FetchContent会尝试从网络下载源码。可以先在一台能联网的机器上把 googletest 源码下载好,放到内网仓库,再把 URL 指向内部地址;或者直接把源码解压到项目第三方目录,用add_subdirectory的方式引入。这些细节解决了,内网开发才不会卡在工具链上。
5.4 MSVC 下运行测试找不到 VC++ 运行库
Windows 上最常见的一个现象是:本地编译链接都正常,但拷贝到另一台机器上运行时,提示缺少msvcp140.dll或vcruntime140.dll之类的文件。这不是项目代码的问题,而是目标机器缺少 Microsoft Visual C++ Redistributable。到微软官网下载对应版本的 x64 redistributable 安装即可。
我在 CI 中处理这个问题更简单:直接使用 GitHub Actions 自带 Windows 镜像,它会预装该运行库;如果是自建的 Windows 构建机,建议在安装系统后把它作为基础软件固定装上。不要试图把一堆 dll 手工复制到系统目录,后续升级或卸载时会造成更多混乱。正规安装运行库版本才是规范做法。
我个人的一点体会
这套测试基建铺完之后,最大的变化不是 bug 数量立刻归零,而是我开始敢碰那些以前“不太敢动”的历史代码。重构之前,跑一遍相关测试模块;重构之后,再跑一遍。如果全绿,我有底气继续往下改;如果有红,我能马上知道自己破坏了什么。这种反馈节奏,是任何代码评审和文档都没法替代的。
单元测试这件事,最难的不是写测试,而是让人相信“写测试的时间是投资而不是成本”。我在项目中证明它的方式,是一次线上事故定位只花了十分钟,而以前的排查流程至少要一小时。如果你也想在团队里推动这件事,我的建议是从被测业务里挑一个反复出问题的函数开始,两三天内让它进入测试保护之下,然后用结果说话。亲测,这种“先小后大、先痛点后全面”的打法,是最容易见效的。