做C++开发几年之后,你会发现一个特别反直觉的现象:明明越到后期越需要胆大心细,但改起代码来却越来越畏手畏脚。改一个接口,牵一发动全身,编译能过,运行也正常,可你就是不敢确定有没有把某个角落里的状态搅乱。这时候我和团队同事坐下来复盘,结论出奇一致——不是代码烂,而是我们根本没有一张安全网。于是"单元测试在C++项目中的实践"这个专项整治就开始了。
这篇博客我会把这几个月走过的路、踩过的坑、沉淀下来的套路全部摊开来讲,包括框架选型、CMake工程搭建、测试代码怎么写才算有效、怎么让老代码变得可测试,以及最后如何把测试跑进CI和覆盖率统计里。如果你所在的项目还没正经写过单元测试,或者写过几笔但没坚持下来,这篇文章适合你;如果你是那种明明框架都装好了,却因为链接报错、编译太慢、测起来无从下手而放弃的人,这篇文章也适合你。
1. 项目概述:C++单元测试到底难在哪
1.1 C++单元测试为什么推行不起来
先说一个脏现实:很多C++项目不做单元测试,不是开发者懒,而是成本实在高。对比Python或Java,跑一个单元测试就是一条命令的事,解释器一启动,函数一调用,完事儿。C++呢?先得有一套能单独拎出来编译的工程结构,得处理各种链接关系,得忍受编译等待,还得面对一个非常朴素的问题:被测代码根本没有设计成可调用的样子。
我见过太多业务代码,数据全部写在全局的单例里,外部依赖全在构造函数里new死,逻辑和IO揉成一团。这种代码不是"能不能测"的问题,是"根本没法测"。你搭好了一个测试架子,结果发现自己连一个能独立运行的对象都构造不出来。这其实是很多人放弃单元测试的真正原因——他们在动手写测试之前,先撞上了代码设计的墙。
所以要推C++单元测试,绝对不能上来就喊"大家写测试"。你得先让大家写出来的测试能跑起来,然后再靠测试的反馈倒逼代码的可测性改造,这两件事是互相成就的。
1.2 这一次,我们要解决的问题面
我们这个项目团队接手的是一个模块边界已经有点模糊的C++业务服务,核心逻辑散落在十几个类里,一部分是纯计算,一部分牵扯数据库和网络。这次实践明确要解决三个问题面:
第一,回归保护。我们后面要做一轮大规模重构,没有测试兜底,重构就是闭眼开车。第二,开发效率。从"人工自测靠感觉"变成"机器验证有依据",减少反复点界面、打日志的验证成本。第三,代码健康度。借测试的视角把隐藏的耦合点暴露出来,逼着我们拆接口、理依赖,而不是继续堆面条代码。
从结果看,这三件事全都做到了。前后大概两周时间,我们给核心模块补了将近400个测试用例,覆盖率从0到了45%左右,重构的底气明显不一样了。
1.3 整体路径回顾:从无到有的四步走
整个推进路径其实可以压缩成四步,后来我给别的团队分享时也反复用这个框架:
第一步,选定框架和工具链,统一编译入口和测试发现机制。第二步,搭一个最小可运行的测试工程,哪怕先测一个加法函数,也要保证ctest能跑通。第三步,从纯计算类入手,补测试案例,逐步扩大到有外部依赖的类,同时做依赖注入改造。第四步,接入CI和覆盖率统计,让测试成为每一次提交的必经关卡。
这四步看着简单,但每一步都有各自的坑。下面我按这个路径挨个讲,其中很多细节是我们反复调整后才摸索出来的,照做基本不会踩偏。
2. 测试框架选型与工具链准备
2.1 主流框架横向对比
C++的测试框架不像Java的JUnit一家独大,可选方案很多,如果不先想清楚自己的需求,光在选型上就能纠结好几天。我在这次实践中认真对比了四个主流框架,分别是GoogleTest、Catch2、doctest和Boost.Test。
GoogleTest的生态最完整,断言宏非常丰富,致命与非致命断言的区别处理得很精细,还有Test Fixture、参数化测试和GoogleMock模拟框架配套,我们对接口隔离和外部依赖的测试需求可以一套工具全解决。缺点是依赖稍重,编译时间相对长一些,但用现代CMake的FetchContent管理之后,这点代价完全可以接受。
Catch2的特点是单头文件或少量文件引入,测试用例的书写风格更贴近BDD,测试名直接可以用一句话描述行为。如果你的项目是那种快速原型风格,Catch2上手非常舒服。doctest号称编译开销最低,性能极强,适合源码文件多、编译慢得让人崩溃的大型库工程。Boost.Test则更适合那些已经把Boost当标配的团队,优势是与现有依赖不冲突,缺点是文档风格偏老派,写起来没那么爽。
| 框架 | 依赖复杂度 | 断言能力 | Mock支持 | 编译开销 | 适用场景 |
|---|---|---|---|---|---|
| GoogleTest | 较高 | 强 | 自带GoogleMock | 中等偏高 | 工程化完整、需要回归保护的业务项目 |
| Catch2 | 较低 | 中 | 需另配 | 中等 | 测试即文档、偏好BDD风格 |
| doctest | 极低 | 中 | 需另配 | 很低 | 大型库工程、编译时间敏感 |
| Boost.Test | 高 | 中 | 需另配 | 较高 | 已重度依赖Boost的老项目 |
这个表格基本能覆盖大多数选型场景。我的建议是:新项目、团队没有历史包袱的,闭眼选GoogleTest;老项目编译时间已经爆炸的,优先试doctest。最忌讳的是五个人五种框架,最后测试代码比业务代码还难统一。
2.2 我为什么最终选了GoogleTest
坦白说,这次选型我没有特别标新立异,最终选了GoogleTest,核心就三个理由:断言体系、配套生态、社区资料量。
断言这块,GoogleTest做了其他人没做透的一件事,就是区分EXPECT_*和ASSERT_*。前者失败后继续执行,后者直接终止当前用例。这个区别在实测里特别重要,很多bug需要在失败后继续看后续的状态,而有些前置条件一旦错了,再往下跑只会刷屏一堆无意义错误。这种"可控失败"的精细度,其他框架很难企及。
生态上,我们很快就要处理外部依赖,Mock工具是一大刚需。GoogleTest自带的GoogleMock不需要额外引入框架,和断言体系的配合也顺滑。资料量更不用说,任何你想到的疑难问题,基本都能搜到答案。选测试框架本质上选的是学习成本和问题解决成本,GoogleTest在这两块的综合得分最高,所以我选了它。
2.3 开发环境与构建系统准备
这次实践我们统一用的工具链是:Windows/Linux双平台开发,编译器分别是MSVC和GCC 9以上,构建系统全部切到CMake。IDE方面,有人用Visual Studio,有人用VSCode配C++插件,还有人用CLion,这些都不影响,因为构建以CMake为准。
有句话说得很对:单元测试能不能推行下去,一半取决于测试跑起来顺不顺。而测试跑起来顺不顺,又完全取决于构建系统配置。所以这一节我强烈建议你把它当模板抄走,后面的章节都会基于这个工程结构展开。
3. 从零搭建一个可运行的最小测试工程
3.1 目录结构怎么摆才不后悔
搭工程结构是第一步,也是很多人随便对待、后面哭都哭不出来的地方。我见过把测试文件乱塞和被测代码混在一起的工程,也见过一个巨大的src目录里放了2000个文件的工程。这两种结构对推行测试都是灾难。
我这次采用的是社区里非常常见但同时很稳的三段式结构——include、src、tests。include放对外暴露的头文件,src放实现和内部头文件,tests放所有测试代码,一个被测模块对应一个xxx_test.cpp。这样做的直接好处是:编译单元划分清楚,测试文件不会污染生产代码;同事之间互相review测试和业务代码的边界也一目了然。
如果你是从老项目改造,不可能一下子把移动文件做完,那就先在根目录建tests,只把新增测试放进去,等后续重构时再慢慢归拢。记住一个原则:测试文件永远不要和被测实现放在同一个目录下编译,否则很容易出现测试代码被误发布到生产环境的问题。
3.2 CMake集成测试框架的完整写法
工程结构定下之后,CMake的配置是这个流程里最容易卡壳的一环。我用的是FetchContent方式直接把GoogleTest源码拉下来参与构建,好处是跨平台自动处理,不需要系统预装。如果你们公司网络环境受限,可以先手动下载后改为add_subdirectory引入,原理一样。
我给出一个完整可用的CMakeLists.txt,你们拷贝之后把项目名和源文件列表替换掉就行:
cmake_minimum_required(VERSION 3.16) project(DemoTestProj VERSION 1.0.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) enable_testing() # 引入GoogleTest include(FetchContent) FetchContent_Declare( googletest URL https://github.com/google/googletest/archive/refs/tags/release-1.12.1.zip ) FetchContent_MakeAvailable(googletest) # 被测代码:做成静态库,方便测试链入 add_library(core_lib src/calc.cpp src/order_service.cpp ) target_include_directories(core_lib PUBLIC include) # 测试可执行文件 add_executable(unit_tests tests/test_main.cpp tests/calc_test.cpp tests/order_service_test.cpp ) target_link_libraries(unit_tests PRIVATE core_lib gtest_main) target_include_directories(unit_tests PRIVATE include) include(GoogleTest) gtest_discover_tests(unit_tests)这段配置里有三个关键点值得展开说。
首先,enable_testing()和include(GoogleTest)是配套的,前者让ctest命令可用,后者提供gtest_discover_tests宏来自动扫描测试用例。这样你新增一个TEST宏,不需要手动注册,直接重新构建就会自动被发现。
其次,被测代码要单独做成静态库(add_library(core_lib ...)),而不是把src里的源文件直接重复加进测试目标。这样做避免了测试代码和被测代码重复编译,也保证了测试和最终程序链接的是同一份实现。
最后,链接gtest_main而不是裸gtest。gtest_main提供main函数入口,省去你自己写一个RUN_ALL_TESTS()的样板代码。test_main.cpp里我们其实只保留了一个空的main占位,留出后续做全局初始化或自定义监听器的空间。
3.3 第一个测试用例:编译、运行、跑通
架子搭好之后,我从最没有争议的功能入手——一个纯粹的计算类Calc。别看它简单,写第一个测试的意义在于验证整条链路通不通。只要这个绿点能跑出来,后面补什么测试都是有路可循的。
被测类的头文件长这样:
// include/calc.h #pragma once class Calc { public: int add(int a, int b); int divide(int a, int b); };实现:
// src/calc.cpp #include "calc.h" #include <stdexcept> int Calc::add(int a, int b) { return a + b; } int Calc::divide(int a, int b) { if (b == 0) { throw std::invalid_argument("divide by zero"); } return a / b; }第一个测试文件:
// tests/calc_test.cpp #include <gtest/gtest.h> #include "calc.h" TEST(CalcTest, AddReturnsSum) { Calc calc; EXPECT_EQ(calc.add(2, 3), 5); EXPECT_EQ(calc.add(-1, 1), 0); EXPECT_EQ(calc.add(0, 0), 0); } TEST(CalcTest, DivideByZeroThrows) { Calc calc; EXPECT_THROW(calc.divide(1, 0), std::invalid_argument); }构建与运行命令如下(Linux环境为例):
mkdir build && cd build cmake .. make -j8 unit_tests ./unit_tests跑通之后你应该看到类似这样的输出:
[==========] Running 2 tests from 1 test suite. [----------] Global test environment set-up. [----------] 2 tests from CalcTest [ RUN ] CalcTest.AddReturnsSum [ OK ] CalcTest.AddReturnsSum (0 ms) [ RUN ] CalcTest.DivideByZeroThrows [ OK ] CalcTest.DivideByZeroThrows (0 ms) [----------] 2 tests from CalcTest (0 ms total) [----------] Global test environment tear-down [==========] 2 tests from 1 test suite. (0 ms total)那一沓绿色的[ OK ]就是我前面说的安全网的第一根线。这里有个经验:别小看这个简单的加法测试,它可以用来验证整个工具链是否顺畅。如果你单位里新增了人,第一件事就是让他把测试跑绿,比写十页文档管用得多。
3.4 测试程序的命令行用法与常用参数
GoogleTest跑起来之后,有几个命令行参数是高频使用的,我建议每个人都记下来。
最常用的是--gtest_filter,单独跑指定的用例或指定的测试套件。比如只跑CalcTest下的所有用例:
./unit_tests --gtest_filter=CalcTest.*也可以跳过某个已知失败的用例:
./unit_tests --gtest_filter=-CalcTest.DivideByZeroThrows还有一个容易被忽视但非常实用的参数叫--gtest_repeat,用来复现偶现问题。当你怀疑某个用例偶尔挂,可以循环1000次:
./unit_tests --gtest_repeat=1000 --gtest_break_on_failure--gtest_break_on_failure会在失败时触发调试器断点,对排查偶现崩溃帮助很大。如果你觉得用例执行顺序会影响结果,再用--gtest_shuffle打乱顺序,能帮你暴露一堆测试之间共享状态的隐藏问题。这些都是实测下来极其好用的救命功能。
4. 核心测试写法、断言技巧与设计改造
4.1 断言体系:致命断言与非致命断言怎么选
很多新手写测试,断言就是一律EXPECT_EQ,写到最后整个测试看起来没问题,但错误信息又长又吓人。这里的关键是要理解ASSERT_*和EXPECT_*的分工。
ASSERT_*是致命断言,失败立刻结束当前用例。EXPECT_*是非致命断言,失败后继续执行。选哪个其实有一条经验法则:如果后续代码的执行依赖前面结果的正确性,用ASSERT_*,否则用EXPECT_*。
举个例子,你要测试一个数据库操作类,先要把数据库连接起来,如果连接这一步就失败了,后面跑任何操作都没有意义,这时候就必须用ASSERT_TRUE(conn->open()),而不是EXPECT_TRUE,否则错误信息会刷一堆“无法连接数据库”。反过来,测试一个对象的多个独立属性,三个属性之间毫无关联,那三个断言都用EXPECT_*,一次用例就能把所有失败信息全给你反馈出来,省得多轮跑测试。
除了*_EQ和*_TRUE,异常断言也强烈建议熟练用。C++代码里异常是常见行为,EXPECT_THROW和EXPECT_NO_THROW能在语言层面把异常逻辑也纳入测试范围。我们团队甚至形成了约定:凡是抛异常的分支,必须写异常断言,不许只写注释。
4.2 测试夹具:为多个用例铺路与收尾
当你开始测一个稍微复杂的类,很快会发现每个用例都要做相同的准备工作,比如创建仓库对象、初始化处理器、喂测试数据。如果这些代码直接复制到每个TEST里,代码会迅速臃肿,而且哪天初始化逻辑变了,你会面临到处改的噩梦。
GoogleTest的夹具机制解决的就是这个问题。你写一个继承::testing::Test的类,把公共的初始化放SetUp(),把清理放TearDown(),然后用TEST_F代替TEST。我拿我们项目里的订单处理器举例:
// tests/order_service_test.cpp #include <gtest/gtest.h> #include <memory> #include "order_service.h" #include "in_memory_order_repo.h" class OrderServiceTest : public ::testing::Test { protected: void SetUp() override { repo_ = std::make_unique<InMemoryOrderRepo>(); service_ = std::make_unique<OrderService>(repo_.get()); repo_->AddSampleOrder(1001, "pending"); } void TearDown() override { service_.reset(); repo_.reset(); } std::unique_ptr<InMemoryOrderRepo> repo_; std::unique_ptr<OrderService> service_; }; TEST_F(OrderServiceTest, CancelPendingOrderChangesStatus) { bool ok = service_->CancelOrder(1001); EXPECT_TRUE(ok); EXPECT_EQ(repo_->GetOrder(1001).status, "cancelled"); } TEST_F(OrderServiceTest, CancelMissingOrderReturnsFalse) { bool ok = service_->CancelOrder(9999); EXPECT_FALSE(ok); }这里有个很多文档不会提的细节:SetUp()里我们用repo_->AddSampleOrder(1001, "pending")准备数据,每个用例进来都会执行一遍,所以两个用例之间根本不需要清理上一个用例的数据。TearDown()里的reset()看起来像多此一举,但是对于那些持有文件句柄或数据库连接的类,显式释放资源能防止测试之间的资源泄漏串扰。
4.3 参数化测试:一份用例喂多组数据
纯计算类和算法类的测试最容易面临另一种痛苦:规则是一样的,输入输出却要覆盖很多组。如果每个输入都写一个TEST,工程师会写到怀疑人生,于是要么偷懒只测两个极端,要么测试文件膨胀到几万行。
参数化测试的价值就在这里。用TEST_P和INSTANTIATE_TEST_SUITE_P,一份用例逻辑可以喂多组数据。还是拿我们改过的一个判断质数的工具函数做例子:
#include <gtest/gtest.h> #include "math_utils.h" class PrimeTest : public ::testing::TestWithParam<std::pair<int, bool>> {}; TEST_P(PrimeTest, ChecksPrimality) { auto [input, expected] = GetParam(); EXPECT_EQ(IsPrime(input), expected); } INSTANTIATE_TEST_SUITE_P( PrimeCases, PrimeTest, ::testing::Values( std::make_pair(2, true), std::make_pair(3, true), std::make_pair(9, false), std::make_pair(17, true), std::make_pair(100, false), std::make_pair(999983, true) ));这样做的好处有两个。第一是数据驱动,新增一条用例只需要往Values里加一对参数,不用新写代码。第二是失败信息更直观,哪一组参数失败了,输出里直接带着参数值,不用自己去猜数据。
后来我们测SDK回调函数、排序算法边界、字符串解析规则时用的都是这套模式,补测试的效率翻了好几倍。如果你是多参数组合,Values里套Combine就能生成笛卡尔积,测接口适配特别方便。
4.4 让被测代码可测试:依赖注入与接口隔离
前面说过,C++单元测试最大的敌人是不可构造的代码。这里我展开讲讲我们怎么拿测试倒逼设计改造。
我们项目里最典型的问题类长这样:
// 改造前 class OrderService { public: OrderService() { db_ = new Database("mysql://localhost:3306/orders"); cache_ = new RedisCache(); } bool CancelOrder(int orderId) { db_->Update(orderId, "cancelled"); return true; } private: Database* db_; RedisCache* cache_; };这种类在单元测试里根本没法测,因为构造函数里直接访问了数据库和缓存。现实中很多开发为了写测试,会尝试用测试环境模拟数据库,结果发现每个测试用例要准备一整套环境,跑一次要几分钟,最后放弃。
正确的解法是依赖注入。把具体依赖改成抽象接口,对象由外部创建后传入:
// 改造后:接口定义 class IOrderRepo { public: virtual ~IOrderRepo() = default; virtual bool UpdateStatus(int orderId, const std::string& status) = 0; virtual Order GetOrder(int orderId) = 0; virtual void AddSampleOrder(int id, const std::string& status) = 0; }; // 构造函数注入 class OrderService { public: explicit OrderService(IOrderRepo* repo) : repo_(repo) {} bool CancelOrder(int orderId); private: IOrderRepo* repo_; };测试里只要实现一个内存版的IOrderRepo,或者直接用StrictMock来模拟这个接口,一举解决依赖问题。
这个过程会有相当一部分人抵触,觉得纯为了测试改接口设计是多余的。我的观点很明确:这是投资,不是浪费。依赖注入本身就让代码的边界更清晰了,你在测试里获得的自由度,恰恰是未来换数据库实现、加缓存策略时的自由度。换句话说,不是测试需要这种设计,而是好代码本来就长这样。
4.5 用Mock替换外部依赖时的几点心得
Mock是C++单元测试绕不开的话题。GoogleMock配合GoogleTest,我用了三个月之后总结出几条非常实用的心得。
第一,能用手写Fake就用Fake,不到万不得已不用Mock。手写Fake就是一个测试目录下的简单实现类,它更贴近真实行为,比如内存版仓库真的保存数据、返回数据,它的语义更接近线上行为。Mock则适合验证调用次数和交互顺序,过度Mock会让测试退化成对实现细节的逐行校验,改一行代码就要改五个测试,维护成本极高。
第二,Mock的行为期望别写太死。我见过同事用EXPECT_CALL把每次调用的参数都严格写死,稍微变化就崩。正确姿势是只约束关键参数,无关的用_匹配器,留出合理的灵活性。
第三,在GoogleMock中,StrictMock比NiceMock更适合早期发现问题,但会很吵;NiceMock则默认不校验未预期调用。我们团队目前的做法是:关键接口用StrictMock,普通依赖用NiceMock,避免测试全挂在无关调用上。
5. 把单元测试嵌进日常开发流程
5.1 与CMake/CI集成,失败即中断
测试写得再多,如果只在本地跑、不进流水线,那它很快就沦为摆设。这个道理大家都懂,但真正把测试嵌入CI的团队还是少数,原因往往是不知道怎么优雅地定义“测试通过”这个条件。
我们采用的标准是:任何一次提交如果导致构建失败或者单元测试失败,CI立刻中断,分歧先丢到自动检查这一环。具体到CMake工程,做法不算复杂:CI里执行构建后跑ctest,用ctest --output-on-failure来展示失败用例的输出信息。
如果你是Jenkins类流水线,可以定义一个脚本:
cd build cmake -DCMAKE_BUILD_TYPE=Debug .. cmake --build . --target unit_tests -j8 ctest --output-on-failure如果用的是GitLab CI,直接在.gitlab-ci.yml里加上类似命令就行。这里有一个很关键的实践细节:CI上跑的代码必须是和本地完全一致的产物,不要让CI和本地构建环境漂移。我们的做法是CI用固定的构建镜像,里面锁定CMake版本、编译器版本和依赖版本。
5.2 覆盖率统计与阈值红线
跑了测试之后没有覆盖率统计,你根本不知道安全网漏没漏。覆盖率是单元测试里最容易走偏也最容易被误解的指标。它不能保证你的代码正确,但能告诉你哪些代码完全没有被执行到——这两件事的置信度有天壤之别。
我们用的工具是GCC自带的gcov配合lcov和genhtml,流程如下:
cd build cmake -DCMAKE_CXX_FLAGS="--coverage" -DCMAKE_EXE_LINKER_FLAGS="--coverage" .. make -j8 unit_tests ./unit_tests lcov --capture --directory . --output-file coverage.info lcov --remove coverage.info '*/tests/*' '/usr/*' --output-file coverage_filtered.info genhtml coverage_filtered.info --output-directory coverage_html打开coverage_html/index.html就能看每个文件的覆盖率。关键问题来了:覆盖率红线设多少?
我的建议是别一上来就定死80%或90%,那是KPI思维,不是工程思维。我们当时的策略分阶段:第一阶段只要求核心纯计算模块的语句覆盖率达到50%以上;第二阶段再慢慢往80%冲;对IO、嵌入式、异步分支这类测试成本极高的代码,单独评审,不强求覆盖率。覆盖率是工具,不是目的,为了刷覆盖率而写一堆只执行不看结果的测试,是对整个实践的伤害。
5.3 测试驱动重构:有测试和没测试的区别
这次实践最重要的收获之一,是让我真真切切体会到了测试驱动重构的力量。过去我们重构一个模块,基本流程是:改代码、编译、跑一把程序、点几个功能按钮,然后祈祷线上别出问题。现在流程完全不同了。
我们做的第一个大重构是对订单状态机的调整。这个模块有6种状态、8种迁移规则,人工验证要来回切换十几步。有了参数化测试之后,我们把所有迁移规则变成数据表,一行数据就是一条规则。重构完,跑一遍测试,两百多个用例全绿,我只花了半小时就确信这次改动没问题。这在以前是不可想象的。
这里我想强调一个容易误导人的观念——不仅是“改动小的地方才敢改”,而是“改动大的地方因为有测试反而敢快速大改”。过去没有测试,重构要控制影响范围,修改粒度一缩再缩,代码最后改得更乱。有了测试作依托,重构可以走“大换血”的路子,改完测试告诉你哪里漏了,边界情况在哪,然后你逐个修。那种掌控感,是纯人工测试给不了的。
5.4 维护测试:成本控制与团队协作
测试代码写出来之后,它自己也变成了需要维护的代码。新鲜感一过,很多人就会感受到维护测试的成本。我这里说几个原则,是这几个月用真金白银换来的。
原则一,测试不能跟着实现走,要跟着行为走。如果测试方法名叫CancelPendingOrderChangesStatus,那不管内部怎么改,外部行为不变量不变,测试就该一直成立。一旦重构导致大量测试修改,多半不是重构有问题,而是测试耦合到了实现细节。
原则二,测试代码也要做Review。我们团队在Code Review时明确要求,凡是新增业务代码,必须有对应的测试文件;凡是修改测试,必须写明原因。这一条看似管理色彩很重,但它保证了测试的演化是受控的,而不是悄悄删掉。
原则三,把测试的编译时间视为一等公民。C++测试编译慢是公认痛点,我们的应对措施是被测核心代码单独编译成库,测试增量只编译测试文件本身,而不是每次把所有源码都重新编译一次。实测下来,增量构建能控制在10秒内,这个体验直接决定了团队成员愿不愿意频繁跑测试。
6. 常见问题速查与避坑实录
6.1 链接错误:undefined reference与重复定义问题
C++单元测试第一个拦路虎就是链接错误。我见过新手配置完CMake后,编译出一堆“undefined reference totesting::internal::...”然后当场崩溃,其实这就是典型的本末倒置——该链接的库没链上,或者链错了。
最常见的原因有三个:一是target_link_libraries漏掉了gtest_main,只链了gtest,导致找不到main函数相关的符号;二是被测代码的源文件没有加入core_lib,测试链接时找不到被测函数的实现;三是在Windows上用MSVC时,GoogleTest的运行库和项目设置不一致,导致/MT和/MD冲突。
如果遇到链接错误,我的排查顺序是固定的:先看错误涉及哪个函数,再确认这个函数是哪个库导出的,然后检查target_link_libraries是否包含了对应的库目标,最后看CMake输出里实际参与链接的源文件是不是最新状态。绝大多数问题都能在这四步里解决。
6.2 测试运行慢:单测当集成测试写了
测试越写越多之后,另一个非常典型的问题是测试运行时间越来越长。我们曾有一次全量跑测试用了20分钟,当场把CI憋坏了。这时候需要先做一个检查:你是不是把单测当成集成测试写了?
具体的病征包括:测试里真的去连数据库、真的发起HTTP请求、真的读写临时文件、测试经常sleep等待异步完成。这些行为的共同特点是它们把所有测试都变成了几十毫秒甚至秒级的慢测试,而且还会互相干扰。
我们的整改方向是:所有外部IO一律替换成Fake或Mock,所有等待异步的操作改成依赖注入事件循环,测试代码里禁止出现sleep。整改完之后,400多个用例全量跑只要3秒。这个速度才能谈得上“每次提交都跑一下”,否则你连跑第二次的耐心都不会有。
6.3 时间、随机数与硬件强相关代码怎么测
业务代码里总有一些依赖系统时间的逻辑,比如判断订单是否超时、生成时间戳。这类代码如果直接取std::chrono::system_clock::now(),测试几乎没法确定结果,因为每次跑的时间都不同。
我们的做法是给代码注入一个“时钟接口”,测试里用可控时钟:
class IClock { public: virtual ~IClock() = default; virtual std::chrono::system_clock::time_point Now() const = 0; }; class RealClock : public IClock { public: std::chrono::system_clock::time_point Now() const override { return std::chrono::system_clock::now(); } };被测类持有一个IClock*,测试时传入固定时间的Fake。这样做之后,超时判断、定时任务逻辑全都变得可以精确预测。
随机数同理,不要直接调用rand()或std::mt19937的默认实例,而是通过依赖注入传入固定种子的随机数引擎,这样测试里的随机场景也可以复制。硬件相关的代码相对难处理,我的建议是尽量把硬件访问收敛到一个极薄的驱动层,业务逻辑层通过接口调驱动,测试时Mock掉驱动层。
6.4 测试代码本身出错怎么办
测试代码也是代码,也会写错。我自己就遇到过测试把期望值写反、算错对比数据的情况,最后测试红了一下午,调试了半天才发现断言里的期望值是错的。这个经历听起来很蠢,但非常普遍。
我的经验是:如果某个测试一直失败,而业务代码看起来没有任何问题,先怀疑测试本身。怎么做?把一个简单的手算结果替换进去,看它是否会变绿。比如测字符串处理函数时,先用硬编码的短字符串测,确认测试逻辑通了再换成复杂数据。千万不要用“换一个更大的数据”来掩盖测试逻辑问题,那样只会越测越糊涂。
同时,看到测试失败信息时,耐心读一下GoogleTest输出的Expected和Actual两个值。很多问题一眼就能看出来是断言里的参数顺序写反了还是计算逻辑错了。这个建议听着低级,但真的能省你很多时间。
6.5 必须守住的三条红线
最后分享三条我们在团队里立下的红线,每条都是用代价换来的。
红线一,禁止为了“让测试通过”而修改被测逻辑以满足测试预期。单元测试的价值在于验证代码真实行为,如果为了绿而改业务,测试就彻底失去了意义,甚至变成欺骗工具。红线的判断方法是:改动是否改变了对外行为,是否能被其他现有用例发现。
红线二,禁止不经评审删除测试用例。删除测试比添加测试更容易,但它往往是盲目自信的开始。我们要求任何删除行为的提交信息必须写明删除原因,并且由另一位同事确认该场景确实无需覆盖。
红线三,禁止把单元测试的成败建立在执行环境假设上。换句话说,测试不得依赖当前工作目录、特定环境变量、固定端口号和真实外部服务。凡是违反这条的测试,跑一百次可能有九十次绿,但一上CI就全崩,最后所有人都会对这套测试丧失信任。
几个月实践下来,我最大的体会是:C++单元测试根本不是“多写几行代码”这么简单的事,它是一次工程价值观的升级。它逼迫你写出构造更简单、依赖更清晰、行为更显式的代码,并且在重构来临的时刻给你真正的底气。如果你正在犹豫要不要在自己的C++项目里推进单元测试,我的建议很直接,不要试图一次覆盖全部模块,先挑一个纯计算类模块,搭好工程骨架,把十来个用例跑绿,再逐步扩大战线。这一小步迈出去,后面的路会越走越顺。等你的测试数量超过300个的时候,你也会像我一样,再也回不到没有测试写代码的日子。