C++单元测试实战指南:从框架选型到CI覆盖率落地

发布时间:2026/10/9 7:07:39
C++单元测试实战指南:从框架选型到CI覆盖率落地 做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_filterCalcTest.*也可以跳过某个已知失败的用例./unit_tests --gtest_filter-CalcTest.DivideByZeroThrows还有一个容易被忽视但非常实用的参数叫--gtest_repeat用来复现偶现问题。当你怀疑某个用例偶尔挂可以循环1000次./unit_tests --gtest_repeat1000 --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_uniqueInMemoryOrderRepo(); service_ std::make_uniqueOrderService(repo_.get()); repo_-AddSampleOrder(1001, pending); } void TearDown() override { service_.reset(); repo_.reset(); } std::unique_ptrInMemoryOrderRepo repo_; std::unique_ptrOrderService 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::TestWithParamstd::pairint, 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_TYPEDebug .. 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个的时候你也会像我一样再也回不到没有测试写代码的日子。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询