我最早对C++单元测试产生执念,是因为接过一个三年前的老模块:编译三分钟,回归靠手工,业务逻辑里到处是static全局变量和一个到处被引用的单例。每次改一处判断都要翻三个文件确认没有其他地方在用。项目里没有任何一个测试文件,我甚至问过“之前的人怎么确认改动没破坏功能的”——得到的回答是“编过了就行,线上有问题再加日志”。
这种状态当然不健康,但我也见过不少C++程序员说:不是不想写测试,是真难。难在哪?难的不是怎么写断言,而是C++这门语言本身的构造方式让“测一个函数”这件事变得异常沉重。这篇文章从我的实际踩坑经验出发,聊C++单元测试的选型、CMake工程如何搭、老代码怎么逐步改成可测试的样子,以及那些让测试莫名其妙变红的问题到底怎么排查。如果你也在维护一套遗留C++项目,或者新项目想从第一天就把测试基础设施建好,这篇文章应该能给你几条能直接落地的路。
1. 为什么很多C++项目连一个测试文件都没有
1.1 编译链接模型带来的“测试慢”
C++很少像Python、JavaScript那样“保存一下就能跑”。一个稍微像样的项目,头文件、源文件、静态库、动态库层层嵌套,编译一个可执行文件动辄几分钟到几十分钟。单元测试意味着你要再拉一套代码进入编译,每次改完测试跑一遍,代价是实打实的编译时间。这在快节奏的项目里很容易成为“不写测试”的理由。
我见过不少团队,测试代码写得很好,但CI里跑一次全套单测要二十分钟,于是大家默认“本地不跑,推到CI再跑”。一旦CI也排队,测试反馈周期拉长到半小时以上,开发者就会慢慢开始绕过测试。
这其实是C++项目落地单元测试的第一道坎:不是技术问题,是习惯问题。习惯的背后是反馈速度。你越能在几秒内得到结果,就越愿意在每次改动后顺手跑一下测试。所以后文里我会特别强调编译速度在框架选型和工程组织中的优先级,这不是无关紧要的细节。
1.2 没有“官方包管理器”带来的框架恐惧
C++没有一个像npm、pip那样人人都在用的官方包管理器。虽然现在有Conan、vcpkg,但在很多公司内部网络环境下,拉一个依赖还是要去GitHub下载源码、本地编译、确认ABI兼容。让一个原本只想“写个测试”的开发者先去折腾依赖,他大概率会选择放弃。
还有一层更隐蔽的问题:ABI兼容。C++标准只规定源码层面的行为,没有规定二进制接口。于是换编译器、换标准库、甚至换编译选项,第三方库可能就要重新编一遍。GoogleTest这种库本身倒是相对温和,但当你引入一堆带第三方依赖的测试工具时,麻烦就来了。我见过有项目因为测试代码引入了某个增加了ABI要求的库,导致整个产品都要跟着调整编译参数,最后不得不把测试依赖砍掉。
所以在C++项目里引入测试框架,选型的第一原则不是“功能最全”,而是“依赖最少、最好集成、编译开销可控”。这个原则直接决定了下面的框架选择。
1.3 单元测试在C++里到底能兜住什么
抛开“单元测试是银弹”的幻想。C++里最难抓的问题有两类:一类是内存错误,另一类是接口回归。
内存错误包括数组越界、空指针、生命周期问题。这类问题很多是未定义行为,调试起来极其费时。单测中哪怕只覆盖几个典型路径,也能在代码提交前暴露其中一部分。
接口回归则更常见。C++没有反射,函数签名变了、参数顺序错了、默认参数改了,调用方在编译期未必会立刻暴露。特别是当你的项目里有很多“头文件接口”和“回调函数”组合的时候,一个函数入参从引用改为指针,可能十几个调用点都要检查。单测能把这些调用点汇总到断言里,一跑就知道哪个行为悄悄变了。
我在实践中对单测的价值排序是:第一保护重构,第二锁定边界行为,第三才是“证明代码没写错”。如果你的项目正处于大量重构阶段,单测的回报会非常明显。
2. 框架选型:GoogleTest、Catch2、doctest怎么选
2.1 三款主流框架横向对比
C++单元测试框架,社区里聊得最多的还是那几个。我给自己项目做过一轮对比,结论放在表格里:
| 框架 | 集成方式 | 编译速度 | Mock支持 | 断言风格 | 适合场景 |
|---|---|---|---|---|---|
| GoogleTest | 源码/find_package/vcpkg | 较慢 | 自带gMock | TEST/EXPECT/ASSERT | 中大型项目,需要Mock |
| Catch2 | 单头文件或CMake集成 | 较快 | 需配合Trompeloeil/FakeIt | TEST_CASE/SECTION/REQUIRE | 中小项目,BBD风格 |
| doctest | 单头文件或CMake集成 | 最快 | 需额外方案 | TEST_CASE/CHECK/REQUIRE | 编译敏感的大型项目 |
GoogleTest功能最全,参数化测试、致命/非致命断言、死亡测试、gMock都是现成的,社区案例多,遇到问题搜得到答案。缺点是编译确实重。一个最小测试目标,编译时间可能比doctest多出两三倍,在多模块项目里会被放大。
Catch2的SECTION机制用来做测试内分支流转很舒服,写起来像描述业务场景,新手比较友好。不过它的Mock能力不是内置的,要额外引入Trompeloeil,这又增加了ABI和版本匹配的负担。
doctest我最初很偏爱,它把这套东西做成一个头文件,编译开销极小,适合那种模块极多、每个模块都想来一套单测的大工程。但它的断言宏丰富度、文档和社区规模明显不如前两者,遇到高级需求(比如mock)集成成本会增加。
2.2 我为什么从doctest切回了GoogleTest
说个真实经历。我们之前有个消息转发模块,用的是doctest,跑得很顺。后来模块里要模拟一个外部网络服务,我需要写一堆Fake类,手写替身越来越痛苦。当时团队商量从doctest切到GoogleTest,因为gMock帮我们省掉了大量样板代码。
但切换的代价也确实让我肉痛:整个测试目标的重编译时间从不到五秒涨到接近三十秒。这个速度对于一个核心模块来说还能接受,但在CI上跑全套测试时,那几个依赖GTest的模块加上依赖关系后,一次完整测试要压缩到分钟级别。
所以我现在的选型建议很直白:
- 团队算法和工具类模块多,追求测试反馈速度,优先doctest。
- 项目中等规模,且以后要大量使用Mock,优先GoogleTest。
- 项目已经深度依赖某个框架,就不要轻易切换,除非测试基础设施收益确实大。
2.3 断言方式的选择:致命与非致命
不管用哪个框架,都会遇到“遇到失败就停”还是“继续跑完”的选择。GoogleTest里是ASSERT与EXPECT,doctest/Catch2里是REQUIRE和CHECK。
我的习惯是:检查前置条件的用致命断言(ASSERT/REQUIRE),比如一个指针必须先解引用才能做后续运算,那空指针就该立刻停;检查结果状态的用非致命断言(EXPECT/CHECK),让一个测试里尽量多暴露几个失败信息,减少来回跑的次数。
还有一个容易被忽略的小点:一个TEST里最好只测一个行为。把十几个断言都堆在一个用例里,一旦中间逻辑改动,失败的定位会变得困难。我倾向于一个用例里围绕一个场景做三到五个断言,宁可多写几个TEST,也不要堆成大杂烩。
3. 基于CMake搭建的测试工程:从零到第一行绿条
3.1 目录与CMakeLists的配置
实操阶段了。先说目录,一般推荐把测试代码放在每个模块的tests子目录里,这样模块和测试自然解耦:
project/ ├── CMakeLists.txt ├── lib/ │ ├── CMakeLists.txt │ └── include/myproject/calculator.hpp │ └── src/calculator.cpp └── tests/ ├── CMakeLists.txt └── test_calculator.cpp根CMake配置里加一个开关,方便在没有测试依赖的环境中跳过测试编译:
cmake_minimum_required(VERSION 3.16) project(MyProject CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) option(BUILD_TESTING "Build tests" ON) add_subdirectory(lib) if(BUILD_TESTING) enable_testing() add_subdirectory(tests) endif()测试子目录的CMakeLists,我用GoogleTest举例:
find_package(GTest REQUIRED) add_executable(unit_tests test_calculator.cpp ) target_link_libraries(unit_tests PRIVATE myproject_lib GTest::gtest GTest::gtest_main ) include(GoogleTest) gtest_discover_tests(unit_tests)GTest::gtest_main很重要,它会自动生成main入口,不用自己写。gtest_discover_tests会扫描可执行文件里的测试用例,注册到CTest,这样ctest命令就能逐个跑所有用例。
如果你不想用系统安装的GTest,用FetchContent拉取源码也很常见:
include(FetchContent) FetchContent_Declare( googletest URL https://github.com/google/googletest/archive/...zip ) FetchContent_MakeAvailable(googletest)这种方式依赖网络,但能保证框架版本一致,适合团队统一构建环境。
3.2 一个测试用例的完整生命周期
写个最简单的覆盖:
// tests/test_calculator.cpp #include <gtest/gtest.h> #include "myproject/calculator.hpp" TEST(CalculatorTest, AddPositiveNumbers) { EXPECT_EQ(add(2, 3), 5); } TEST(CalculatorTest, AddZeroDoesNotChangeValue) { EXPECT_EQ(add(0, 7), 7); }构建之后跑:
cmake -S . -B build cmake --build build cd build && ctest --output-on-failure看到类似输出:
Test #1: CalculatorTest.AddPositiveNumbers ... Passed Test #2: CalculatorTest.AddZeroDoesNotChangeValue ... Passed这就是第一行绿条。关键在于这个流程最好配置成“改完代码一条命令跑全量测试”,而不是打开IDE手动点。我在团队里推荐两条命令:一条增量编译,一条只跑测试,文件都用目录级缓存,尽量让从敲命令到看到结果在十五秒以内。
3.3 测试基境和用例之间的隔离
GoogleTest的SetUp/TearDown能帮你准备公共环境。但有一点我吃过亏:SetUp里创建的资源,必须是每用例独立的,而不能是共享的。因为测试框架不保证用例的执行顺序,也不保证它们互不干扰。
比如你有一个临时文件路径,如果所有用例共用一个文件名,那么用例A删掉它之后,用例B就傻眼了。正确做法是给每个用例带上测试名或随机后缀。同理,静态成员变量和全局对象要尽量避免在测试里用来保存“当前状态”。
如果需要一组测试共享同一个较重的初始化对象,GoogleTest的“测试套件级”SetUp(SetUpTestSuite)可以解决,但要注意对象生命周期和线程安全问题。我的原则是:能用普通局部变量就别提升作用域,测试代码宁可写得啰嗦一点,也不要共享状态。
4. 老代码怎么改成可测试的:依赖注入的最小侵入改造
4.1 不可测代码的典型症状
老C++项目里的代码,不可测往往长这样:
- 函数内部直接创建具体对象,比如
DbConnection conn; - 依赖全局状态,比如一个到处可见的
g_config - 把关键逻辑写在构造函数里,想单独测一个方法还得先构造一堆环境
- 使用static函数且不接受外部注入
其中最典型的场景就是网络请求和时间相关逻辑。一个sendRequest()如果内部直接连真实服务器,单测就没法在离线和无网络环境跑。一个订单创建函数如果内部直接std::time(nullptr),测试里就没法构造固定时间,跨天场景根本测不准。
4.2 接口抽象与依赖注入的最小改动方案
最小改动的思路是:把“直接依赖”替换成“抽象接口”,再让测试注入替身。以时间为例,本来是:
int computeDiscount(Order order) { auto now = std::time(nullptr); // 根据时间算折扣 }改造后,把时间来源抽象出来:
class TimeSource { public: virtual ~TimeSource() = default; virtual std::time_t now() const = 0; }; class SystemTimeSource : public TimeSource { public: std::time_t now() const override { return std::time(nullptr); } }; int computeDiscount(const Order& order, const TimeSource& timeSource) { auto now = timeSource.now(); // 业务逻辑完全不变 }测试里就可以轻松注入固定时间:
class FixedTimeSource : public TimeSource { public: explicit FixedTimeSource(std::time_t t) : fixed(t) {} std::time_t now() const override { return fixed; } }; TEST(DiscountTest, WeekendDiscount) { auto fixed = FixedTimeSource(/*某周末时间戳*/); auto order = makeOrder(); EXPECT_EQ(computeDiscount(order, fixed), expected); }这里的改动点很小:函数签名多了一个参数,调用方在正式代码里传一个系统时间源即可。代价是接口层多一个类,但换来的是整个时间相关逻辑可以脱离真实时间测试。C++里用引用还是指针传递这个接口,我倾向于引用,因为接口在生命周期内必须存在,引用能表达“非空且不拥有”的语义,也更符合调用方直觉。
4.3 回调与Mock的配合
老代码里另一种常见依赖是“回调函数”式的设计。C++里回调可以是函数指针、std::function或虚接口。如果代码本身已经用了std::function,那测试起来反而简单:直接替换成一个记录调用的lambda即可。
比如一个消息处理器,构造时接受处理完成回调:
class MessageProcessor { public: explicit MessageProcessor(std::function<void(const Message&)> onDone) : onDone_(onDone) {} void process(Message msg) { onDone_(msg); } private: std::function<void(const Message&)> onDone_; };测试里注入记录型lambda:
TEST(MessageProcessorTest, InvokesCallbackOnProcess) { std::vector<Message> received; MessageProcessor processor([&](const Message& m) { received.push_back(m); }); processor.process({"hello"}); ASSERT_EQ(received.size(), 1); EXPECT_EQ(received[0].body, "hello"); }如果回调改成虚接口,还能配合gMock的ON_CALL/EXPECT_CALL来做更精细的行为校验。我这里想强调一个原则:优先选择最小侵入方式。能改参数注入就改参数,能引入接口就引入接口,能换成std::function就别急着上重型Mcok框架。Mock是手段,不是目的。
5. 我踩过的坑:链接错误、测试污染、随机数和资源泄漏
5.1 链接错误排查
C++单元测试最常见也最让人窝火的错误就是链接错误。undefined reference to或者multiple definition of,在你代码逻辑完全正确的情况下冒出来。
undefined reference多半是测试目标没链接对应的库,或者源文件没参与编译。排查思路是:看看报错符号属于哪个源文件,确认该文件有没有被加入测试目标的源文件列表,以及链接库的顺序对不对。CMake里静态库链接顺序如果放错,也会导致符号找不到,一般把被依赖的库放在依赖它的库后面。
multiple definition则更多是头文件里定义了变量或非inline函数,被多个源文件包含。C++17以后如果函数定义放在头文件里,记得加inline关键字。我踩过的一次是头文件里写了一个全局const std::string VERSION = "1.0";,多个测试源文件包含后直接链接失败。改成inline const std::string VERSION = "1.0";才解决。
5.2 全局状态污染
测试之间互相影响,大多是因为被测代码内部存在全局/静态状态。你测完用例A,覆盖了某个static变量,用例B再跑时初始值已经变了。
这类问题的特征是:单跑某个测试一定通过,一跑全量就随机挂。排查方法也很古老:用--gtest_filter先定位是哪些用例组合在一起会出问题,然后逐步二分。
根治办法是在SetUp里重置相关全局状态。但更稳妥的是把被测代码里的static状态改成可注入的,或者至少提供一个测试专用的reset函数。这里也要注意GoogleTest的执行顺序虽然遵循声明顺序,但这不算稳定契约,不要让测试依赖执行顺序。
5.3 随机数导致的不稳定用例
C++里很多开发者习惯用std::rand()配srand()生成随机数。但rand()的实现在不同平台表现差异大,而且到处调srand()会污染全局随机状态。在测试里,这是让用例忽红忽绿的经典元凶。
聊到“真正的随机数”,C++11开始有<random>库,std::mt19937配合分布器,质量比rand()好得多,而且可以固定种子复现问题。我改造随机数相关代码时,会把它封装成一个可注入的随机引擎:
class RandomEngine { public: using Engine = std::mt19937; explicit RandomEngine(uint32_t seed) : engine_(seed) {} int range(int low, int high) { std::uniform_int_distribution<int> dist(low, high); return dist(engine_); } private: Engine engine_; };测试里固定种子:
TEST(ShuffleTest, DeterministicWithFixedSeed) { RandomEngine engine(42); auto result = shuffle(engine); ASSERT_EQ(result, expectedForSeed42); }这样随机用例也能变成可复现的确定性用例,遇到随机失败时把种子打出来,就能拿到出错现场的输入。
5.4 资源泄漏与测试并行
如果测试里打开了文件、申请了内存但没有释放,单独看不致命,但跑完几千个用例,进程内存就绷不住了。C++里最简单的规避方式就是RAII:让资源在作用域结束时自动释放。测试代码也不例外。
还有一个容易被忽视的坑是“测试并行”。CTest支持ctest -j4并行跑测试,这时候如果你的测试会写同一个临时目录、同一个文件,就会互相踩踏。我之前负责的模块就出过这类问题:两个测试用例同时往进程当前目录写一个temp.log,谁删谁、谁写谁的都说不清。解决方案是让每个测试生成独立目录,并在TearDown清理。
class TempFileTest : public ::testing::Test { protected: void SetUp() override { dir_ = std::filesystem::temp_directory_path() / ("ut_" + std::to_string(::getpid()) + "_" + std::to_string(counter_++)); std::filesystem::create_directories(dir_); } void TearDown() override { std::filesystem::remove_all(dir_); } std::filesystem::path dir_; private: static int counter_; };这样并行跑也不会冲突。老实说,C++里“测试不稳定”六个字背后往往就是这四大元凶之一:链接没弄对、状态被污染、随机数不可复现、资源或路径冲突。排查的顺序也按这个优先级来。
6. 在已有项目中推行单测的几条经验
6.1 从最容易覆盖、风险最高的模块开始
别一上来就想把全部老代码都套上单测——那不现实,也会让团队失去信心。我成功推行的路径是:挑一类纯算法/工具模块先补覆盖。这类模块没有外部依赖、输出输入明确、测试即写即跑,能快速建立正向反馈。
比如字符串处理、协议编解码、数据序列化,这些模块往往是整段业务里最容易出边界问题的地方,也是单测性价比最高的地方。一批工具库被测稳了,团队就会慢慢建立“改代码不怕了”的体验。
6.2 不是所有代码都值得写单元测试
有些C++代码形态天生不适合单测,比如纯UI层、和硬件交互的驱动、以及大量胶水逻辑。胶水代码的关键行为是“调用正确”,单测写起来痛苦,收益又低,不如交给集成测试。
我在评审测试计划时常用一条标准:如果一个测试要模拟超过三层的环境,同时断言又只能验证“调用发生”,那就该考虑这是否真的是单元测试。单元测试的“单元”应当是一个逻辑行为,而不是一大坨外部环境。
6.3 让测试成为提交门槛
测试写好了,不能在CI里跑等于没写。我推行的最低限度方案是:在CI流水线里加一步ctest --output-on-failure,任何分支合并前必须通过测试。这个要求看着简单,但对老项目来说,初期会让CI红得发紫。
一条比较温和的路径是:先只对新增代码和改动过的模块强制要求测试,历史代码用覆盖率报告慢慢铺。不搞“一刀切百分百覆盖率”,而是要求改动区域的覆盖率达到合理水位。这样既能保证新增行为有保护,又不会让历史债务瞬间压垮迭代节奏。
如果你也在为C++项目的测试荒地发愁,我的建议是先从今天要改的那个小函数开始,给它写一个测试,让它跑起来,让那一条绿条出现。后面的事,都是从这个小小的绿条长出来的。