资讯动态

C++单元测试实战:从框架选型到CI集成与覆盖率

发布时间:2026/10/17 22:13:01 来源:尧图企业网站定制
1. 为什么我要给 C 项目补上单元测试先说个直白的结论C 项目写单元测试不是给别人交代是给自己留后路。我见过太多 C 项目编译一过就觉得自己写完了结果需求一改、模块一拆原来稳如老狗的逻辑翻车翻得莫名其妙。尤其是那些处理字符串、解析协议、操作原始内存的代码出了问题连定位都费劲。单元测试在 C 项目里的作用说白了就是给代码装上一层“安全网”。你改了排序逻辑跑一遍测试就知道有没有把边界条件搞坏你重构了某个类的内部实现测试能告诉你对外行为有没有变。这层网不是给别人看的是让你自己夜里睡得着觉的。这篇文章适合谁看适合那些项目里还没有一个正经测试框架、想上手但不知道从哪开始的 C 开发者。如果你已经在用某个框架写测试也可以重点看后面关于 Mock、覆盖率、CI 集成的部分那里有我踩过的坑。我会从框架选型讲起带你搭一个最小可用的测试工程再用一个真实的解析器代码走完整个测试过程最后分享 CI 集成和覆盖率统计的实操配置。全程用我实际验证过的代码示例你照着做就能跑起来。2. 框架选型GoogleTest 还是 Catch2我的取舍逻辑2.1 三个主流框架的横向对比C 的单元测试框架不少但真正用得多的就三个GoogleTest、Catch2、doctest。我三个都用过简单说说它们的脾气。框架依赖方式编译速度断言宏风格最适合的场景GoogleTest源码编译 / CMake FetchContent较慢EXPECT_EQ、ASSERT_TRUE中大型项目、需要 Mock、参数化测试Catch2Header-only / 编译库较快REQUIRE(x y)、SECTION 嵌套追求简洁、单文件快速上手doctestHeader-only最快类似 Catch2头文件较多的项目、需要极速编译如果你只是写一个小工具、一个算法库Catch2 或者 doctest 就够了头文件一拷一个TEST_CASE直接开测没有任何构建负担。但如果你的项目比较正规比如要测类之间的交互、要 mock 外部依赖、要做数据驱动测试那我建议直接上 GoogleTest。我的选择是 GoogleTest。原因有三个第一GoogleTest 的断言体系最完善。EXPECT_THAT配合 Hamcrest 匹配器写出来的断言可读性极高EXPECT_THAT(result, ElementsAre(1, 2, 3))一眼就能看懂在验证什么。第二GoogleTest 内置了 Mock 框架 gMock不用额外引入第三方的 mock 库。对于 C 这种没有原生反射和动态代理的语言来说mock 这件事很麻烦gMock 能省掉你大量手工造桩类的功夫。第三社区生态成熟。不管你是用 CMake、Bazel 还是别的构建系统接入 GoogleTest 都很方便。出问题搜一下前人早就踩过坑。2.2 一个值得注意的坑Catch2 的 SECTION 嵌套陷阱Catch2 的SECTION是很惊艳的设计可以模拟测试夹具的层级行为但用多了你会发现一个问题同一个SECTION块会在多个测试用例中重复展开每次展开都会重新执行父级代码。如果父级代码里有耗时操作测试总时长会成倍膨胀。我做过一个实验一个包含 3 层SECTION嵌套的测试用例最内层的SECTION会执行 7 次。如果你在最外层做了文件初始化那时间开销是肉眼可见的。这不是 Catch2 的 bug而是它的设计哲学——牺牲一点性能换取测试代码的嵌套自由度。所以我现在的习惯是小项目用 Catch2 或 doctest 快速出活中大型项目直接用 GoogleTest结构清晰团队协作也更统一。3. 工程搭建CMake GoogleTest 从零落地3.1 目录结构从第一天就分清楚很多 C 项目的测试代码放得很随意和源码混在一起最后搞得一团糟。我建议从第一天就按这个结构组织project/ ├── CMakeLists.txt ├── src/ │ ├── CMakeLists.txt │ ├── parser.h │ ├── parser.cpp │ └── main.cpp └── tests/ ├── CMakeLists.txt ├── parser_test.cpp └── fixtures/src只放业务代码tests只放测试代码。为什么这样分因为测试代码的编译配置和业务代码完全不同——测试需要链接 gtest需要开启额外的警告选项需要把-fno-exceptions这类选项关掉。混在一起CMake 配置会变得很别扭。3.2 CMake 接入 GoogleTest 的三种方式对比GoogleTest 官方文档推荐了三种接入方式我把它们都试过列个对比方式优点缺点适用场景add_subdirectory引入源码一次编译全项目共享库源码参与主工程编译变慢离线环境、公司内网FetchContent 在线拉取版本锁定配置最干净首次构建需要联网开源项目、新项目系统包管理器安装vcpkg 等本地编译一次各项目复用环境依赖换机器麻烦个人开发机器我现在最常用 FetchContent因为版本控制最精准。在根CMakeLists.txt里加上include(FetchContent) FetchContent_Declare( googletest URL https://github.com/google/googletest/archive/refs/tags/v1.14.0.zip ) FetchContent_MakeAvailable(googletest)注意URL 里的版本号要固定不要用main分支否则过两天 gtest 更新了你的 CI 可能突然编译失败。版本锁死是工程化的基本素养。3.3 测试目标与运行配置在tests/CMakeLists.txt里这样配置find_package(GoogleTest REQUIRED) add_executable(unit_tests parser_test.cpp ) target_link_libraries(unit_tests PRIVATE parser_lib GTest::gtest_main GTest::gmock_main ) include(GoogleTest) gtest_discover_tests(unit_tests)这里有几个容易踩的坑第一gtest_discover_tests宏会自动扫描可执行文件里所有的测试用例注册到 CTest。它需要一个--gtest_list_tests命令如果可执行文件运行时崩溃或者依赖动态库找不到这个宏会失败。遇到过的话改成手动add_test做兜底。第二链接GTest::gtest_main而不是GTest::gtest。gtest_main自带main函数入口你就不用自己写。如果你需要自定义main比如初始化日志、加载测试数据才链接GTest::gtest然后自己写。第三gmock_main和gtest_main二选一即可。如果你用了 gmock 的功能直接链接gmock_main它内部已经包含了 gtest 的main。然后你就能用一行命令跑全部测试ctest --test-dir build --output-on-failure个人经验ctest以后要在 CI 里用因为它能输出 JUnit 格式的报告本地开发时直接跑./build/tests/unit_tests --gtest_coloryes更快日志格式也更直观。4. 从一段真实代码开始解析器的完整测试实战4.1 被测代码一个简易的 CSV 行解析器为了演示完整的测试过程我们用一段在实际项目里很常见的代码——解析一行 CSV 数据。它要处理引号转义、逗号切割、空字段等逻辑是典型的容易写错又必须测的代码。先看头文件parser.h#ifndef PARSE_CSV_LINE_H #define PARSE_CSV_LINE_H #include string #include vector namespace csv { struct ParseResult { std::vectorstd::string fields; bool ok true; std::string error_message; }; ParseResult parse_line(const std::string line); } // namespace csv #endif再看实现parser.cpp#include parser.h #include sstream namespace csv { ParseResult parse_line(const std::string line) { ParseResult result; if (line.empty()) { result.ok false; result.error_message empty line; return result; } std::string current; bool in_quotes false; bool field_started false; for (size_t i 0; i line.size(); i) { char c line[i]; if (in_quotes) { if (c ) { if (i 1 line.size() line[i 1] ) { current.push_back(); i; } else { in_quotes false; } } else { current.push_back(c); } } else { if (c !field_started) { in_quotes true; field_started true; } else if (c ,) { result.fields.push_back(current); current.clear(); field_started false; } else { current.push_back(c); field_started true; } } } if (in_quotes) { result.ok false; result.error_message unclosed quote; return result; } result.fields.push_back(current); return result; } } // namespace csv这段代码逻辑不长但边界情况很多空行、带引号的字段、引号内的逗号、两个连续引号转义、未闭合的引号、只有逗号没有字段。如果你不写测试光靠肉眼根本不可能在改了十几次之后还确保它全对。4.2 先理清测试用例再动手写代码我写单元测试的习惯是写测试用例之前先在纸上或者注释里列出所有需要覆盖的场景。这个过程看着笨但能避免“测了个寂寞”。我这个解析器的测试用例清单如下用例编号输入预期输出覆盖点T01a,b,c[a, b, c]正常切割T02a[a]单字段T03失败错误信息empty line空行T04a,\b,c\,d[a, b,c, d]引号内逗号T05\a\,b[a, b]带引号的字段和普通字段混合T06a,\b\\c\,d[a, b\c, d]转义引号T07a,\b失败错误信息unclosed quote未闭合引号T08,,[, , ]空字段T09\\,a[, a]引号包围的空字段T10a,b\nc注意这里依赖你如何处理换行包含换行符十组用例一列出来代码的边界性格就清楚了。T06 是我后补的因为我第一次实现时没有处理双引号转义T09 也是后补的因为这个场景我最初完全没有想到。这份清单直接变成测试代码里的注释每一条用例都能追到需求。4.3 测试代码从断言到失败的完整路径有了用例清单写测试代码就是体力活了。直接在tests/parser_test.cpp里写#include gmock/gmock.h #include gtest/gtest.h #include parser.h using namespace testing; using csv::parse_line; TEST(ParserTest, SplitsNormalCsvLine) { auto result parse_line(a,b,c); ASSERT_TRUE(result.ok) error: result.error_message; ASSERT_EQ(result.fields.size(), 3u); EXPECT_THAT(result.fields, ElementsAre(a, b, c)); } TEST(ParserTest, SingleFieldLine) { auto result parse_line(a); ASSERT_TRUE(result.ok); EXPECT_THAT(result.fields, ElementsAre(a)); } TEST(ParserTest, EmptyLineReturnsError) { auto result parse_line(); ASSERT_FALSE(result.ok); EXPECT_EQ(result.error_message, empty line); } TEST(ParserTest, CommaInsideQuotesIsPreserved) { auto result parse_line(a,\b,c\,d); ASSERT_TRUE(result.ok); EXPECT_THAT(result.fields, ElementsAre(a, b,c, d)); } TEST(ParserTest, QuotedFieldAndPlainFieldMix) { auto result parse_line(\a\,b); ASSERT_TRUE(result.ok); EXPECT_THAT(result.fields, ElementsAre(a, b)); } TEST(ParserTest, EscapedQuotesInsideQuotedField) { auto result parse_line(a,\b\\c\,d); ASSERT_TRUE(result.ok); EXPECT_THAT(result.fields, ElementsAre(a, b\c, d)); } TEST(ParserTest, UnclosedQuoteReturnsError) { auto result parse_line(a,\b); ASSERT_FALSE(result.ok); EXPECT_EQ(result.error_message, unclosed quote); } TEST(ParserTest, EmptyFieldsArePreserved) { auto result parse_line(,,); ASSERT_TRUE(result.ok); EXPECT_THAT(result.fields, ElementsAre(, , )); } TEST(ParserTest, QuotedEmptyField) { auto result parse_line(\\,a); ASSERT_TRUE(result.ok); EXPECT_THAT(result.fields, ElementsAre(, a)); }写这段代码时有个关键手法ASSERT_TRUE(result.ok)和EXPECT_THAT(result.fields, ...)必须搭配使用。ASSERT_系列是一旦失败就立即返回后面的代码不再执行EXPECT_系列是失败也继续执行收集完所有失败再统一报告。如果你用EXPECT_TRUE(result.ok)来代替当ok为 false 时result.fields可能还没有被赋值后续的EXPECT_THAT就会访问空 vector。虽然不至于崩溃但断言报错会被干扰排查问题要翻两层。所以这种“先验证前置条件再验证具体数值”的写法是我一直强调的规范。跑一下测试看输出[] Running 9 tests from 1 test suite. [----------] Global test environment set-up. [----------] 9 tests from ParserTest [ RUN ] ParserTest.SplitsNormalCsvLine [ OK ] ParserTest.SplitsNormalCsvLine (0 ms) ... [ RUN ] ParserTest.UnclosedQuoteReturnsError parser.cpp:45: Failure Expected equality of these values: result.error_message Which is: unclosed quote unclosed quote [ FAILED ] ParserTest.UnclosedQuoteReturnsError (0 ms)真没想到第一版代码就漏了这种错误。检查一下问题出在if (in_quotes)分支里当读到引号且后面没有再跟引号时in_quotes被置为 false但如果这个引号恰好是字符串的最后一个字符result.fields.push_back(current)正常执行了但函数返回的ok仍然是 true。修一下实现把in_quotes状态在循环结束后再检查一次。这是单元测试最有价值的时候——它逼着你去想那些“理所当然不会出错”的角落。4.4 测试优先的养成边界情况的固定写法经过这个例子我把边界情况的测试写法总结成了一个模式分享出来你直接套用// 必测的边界场景 // 1. 空输入 // 2. 最小有效输入单个字符 // 3. 最大有效输入这要看业务约束 // 4. 含特殊字符的输入 // 5. 格式非法输入 // 6. 格式合法但语义非法的输入CSV 解析器的边界就是空行、单个字段、引号内的特殊字符、未闭合引号。每一类我都建了一个TEST(ParserTest, ...)的用例不贪多但保证每个类型至少覆盖一次。5. 依赖怎么处理Mock 与依赖注入的实用心得5.1 C 里 Mock 的痛点和破局单测测到模块边界时最头疼的问题就是依赖。比如你的类要访问数据库、要发送网络请求、要读配置文件这些外部依赖在测试环境里要么不存在要么行为不可控测试就变得不稳定。在 C 中处理依赖有三个思路按优雅程度排序方案实现难度适用场景例子依赖注入 接口抽象中等任何类之间交互构造函数传接口指针模板 编译期多态新手不友好但性能好性能敏感的库模板参数指定策略类链接期替换链接 fake 实现简单粗暴只有一个实现、不好改架构的旧代码链接fake_db.cpp代替db.cpp依赖注入是正道但 C 没有 Java 那种反射机制所以你需要先定义接口再用 gMock 生成 mock。假设我们的CsvProcessor依赖一个FileReader接口class FileReader { public: virtual ~FileReader() default; virtual std::string read_all(const std::string path) 0; };gMock 生成 mockclass MockFileReader : public FileReader { public: MOCK_METHOD(std::string, read_all, (const std::string path), (override)); };然后在测试里TEST(CsvProcessorTest, ReadsAndParsesFile) { MockFileReader reader; EXPECT_CALL(reader, read_all(data.csv)) .WillOnce(Return(a,b,c\n)); CsvProcessor processor(reader); auto lines processor.process(data.csv); ASSERT_TRUE(lines.ok); EXPECT_EQ(lines.fields.size(), 3u); }测试就完全控制了FileReader的行为不依赖真实文件系统。文件不存在、文件为空、文件内容非法全都可以用WillOnce(Return(...))模拟方便极了。5.2 写 Mock 的几个坑第一EXPECT_CALL默认要求 mock 方法被调用且匹配如果一个EXPECT_CALL没有匹配到任何调用测试会失败。这在验证“某个依赖没有被调用”时会误报。如果你确实不关心调用次数加.Times(AnyNumber())。第二WillOnce和WillRepeatedly要数学算清楚。如果 mock 方法被调用了 3 次但你只写了 2 个WillOnce第三次调用会返回默认值对std::string就是空串测试很可能因为一个空串而莫名失败错误信息却完全不指向 mock 调用次数。第三接口的析构函数必须是virtual。这是 C 比较隐蔽的坑如果基类析构函数不是 virtualgMock 生成的 mock 对象析构时不会调用真实类的析构逻辑可能导致资源泄漏或未定义行为。我见过一个项目因为这个原因在 CI 上随机崩溃查了两天才定位到。5.3 链接期替换旧代码的最后手段如果你的代码没有接口抽象又实在动不了架构那就用链接期替换。把真实依赖实现放在一个单独的文件里测试时链接一个 fake 版本。假设Database类在db.cpp里测试时不链接它而是链接tests/fake_db.cpp其中Database::query返回固定的假数据。代价是如果你的假实现和真实行为不一致测试会给你虚假的安全感。所以我的底线是链接期替换只用于旧代码的临时过渡新代码一律走依赖注入。6. 测试的持续集成与覆盖率不跑在 CI 上的测试等于没写6.1 在 CI 里跑测试的最小配置本地能跑测试只是第一步测试必须跑在持续集成CI里才能防止某位同事在提交代码时顺手把测试注释掉。一个典型的 CI 流程分三步构建、跑测试、上传报告。用 GitHub Actions 的 YAML 示例其他 CI 平台类似name: ci on: push: branches: [main] pull_request: jobs: unit-tests: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - name: Configure run: cmake -S . -B build -DCMAKE_BUILD_TYPERelease - name: Build run: cmake --build build -j2 - name: Test run: ctest --test-dir build --output-on-failure注意三步分别执行cmake -S . -B build、cmake --build build、ctest千万别图省事在cmake --build之后再执行./build/tests/unit_tests因为ctest还能直接把测试结果注册给 CI 系统的测试报告界面。6.2 覆盖率统计用 gcov 和 lcov覆盖率是个好东西但别被数字骗了。我见过有人为了把覆盖率从 80% 打到 95%写了大量只为了“覆盖”而存在的断言这些断言除了让数字好看没有任何行为验证能力。我的建议是覆盖率看趋势不看绝对值覆盖率低于 60% 说明测试重量不足高于 90% 往往意味着开始堆砌无意义断言。用 gcov 和 lcov 统计覆盖率的基本流程# 编译时开启 coverage 选项 cmake -S . -B build-coverage \ -DCMAKE_BUILD_TYPEDebug \ -DCMAKE_CXX_FLAGS--coverage -O0 cmake --build build-coverage -j2 # 跑测试 cd build-coverage ctest --output-on-failure # 生成覆盖率报告 lcov --capture --directory . --output-file coverage.info lcov --remove coverage.info \ /usr/* \ */tests/* \ googletest-src/* \ --output-file coverage_filtered.info genhtml coverage_filtered.info --output-directory html_report--coverage会同时设置-fprofile-arcs和-ftest-coverage告诉编译器插入代码覆盖率统计代码。注意-O0编译优化开启后行号会和源码对不上覆盖率的报告就没有参考意义了。在 CI 的 YAML 里加一步上传 artifact- name: Upload coverage report uses: actions/upload-artifactv4 with: name: coverage-report path: build-coverage/html_report/6.3 关于代码覆盖率阈值的反思我在一个项目中设置过覆盖率阈值低于 70% CI 就失败。后来发现这套策略引发了大量“为数字而测”的代码。后来我们把策略改成增量代码覆盖率阈值 全量代码覆盖率监控。增量覆盖率只统计这次提交改动过的代码必须达到 80%全量覆盖率只记录数值发到团队群里不设硬性门槛。这个策略显著减少了无意义断言团队也更愿意在关键逻辑上写更有深度的测试。当然覆盖率的实现细节和语言生态有关但思路是通用的覆盖率是反馈信号不是 KPI。7. 单测里最常见的问题与我的排查清单7.1 测试对脏数据的抵抗力C 单元测试里最容易犯的错误之一就是测试代码本身对数据有隐含假设。比如有一个测试用例从一个固定路径读取配置但这个路径在 CI 环境里不存在。这种测试本地能过CI 必挂让人抓狂。我建议所有测试文件都不要依赖相对路径——要么把测试数据放在 fixture 目录里并通过 CMake 定义宏传递路径要么把数据内容直接写在测试代码里。我倾向于后者数据量小的时候直接内联数据量大才用 fixture 文件。在 CMake 里传递数据路径的方法target_compile_definitions(unit_tests PRIVATE TEST_DATA_DIR${CMAKE_CURRENT_SOURCE_DIR}/data )然后在测试里const std::string data_dir TEST_DATA_DIR; std::ifstream fin(data_dir /sample.csv);7.2 浮点比较不能让直接上C 单测另一个高频问题浮点断言用EXPECT_EQ或者ASSERT_EQ比较两个浮点数。0.1 0.2不等于0.3这是基础的浮点误差问题但每过一段时间还是会有人踩进去。GoogleTest 内置的处理方式是EXPECT_FLOAT_EQ(a, b); // 适用于 float EXPECT_DOUBLE_EQ(a, b); // 适用于 double这两个宏内部会做误差容差比较。如果你需要指定误差范围EXPECT_NEAR(a, b, 1e-6);1e-6的选取要看你的业务精度要求。给我一个反例一个项目用EXPECT_EQ比较两个重力加速度计算值每次跑测试的结果在最后几位都不一样浪费了团队一下午时间排查。换了EXPECT_NEAR之后问题直接消失。7.3 测试之间的状态污染GoogleTest 默认每个测试用例都是独立运行的但你如果写了全局变量、静态变量、文件缓存测试之间就可能互相污染。我遇到一个典型的场景一个日志组件用静态布尔变量控制是否初始化第一个测试用例初始化了日志第二个测试用例假设日志从未初始化结果行为不符合预期。解决方式有两种一是尽量不写有状态的全局对象二是用 GoogleTest 的SetUpTestSuite和TearDownTestSuite来管理套件级状态。注意套件级状态是共享的如果某个测试改坏了状态后面的测试全部遭殃所以能不用就不用局部变量优先。我的习惯是每个测试用例自行构造自己需要的对象不共享任何可变状态。测试代码的隔离性决定了故障排查的时候你是花十分钟还是一整天。7.4 排查测试失败时的操作顺序作为总结给一份排查测试失败的顺序清单先看断言本身有没有写错很多失败是断言写反了比如把EXPECT_TRUE(x)写成了EXPECT_FALSE(x)。再确认脏数据打印出实际的输入输出比对一下预期和实际差在哪。检查测试之间的状态污染单独跑这一个测试用例./unit_tests --gtest_filterClassName.TestName。如果单独跑能过全量跑挂掉那大概率是状态污染。如果是偶发性失败多跑几次./unit_tests --gtest_repeat10 --gtest_break_on_failure--gtest_break_on_failure在第一次失败时就停住方便抓现场。反复跑不稳定的用例多半会暴露竞态条件或者随机数问题。8. 性能相关的测试别让单测框架背性能的锅8.1 定时测试与超时的权衡C 单测最常见的超时场景是死循环或者某段代码复杂度爆炸。GoogleTest 不内置超时机制你需要自己用线程和 Promise 来实现。一个轻量级的实现思路不完美但够用#include future #include chrono template typename Func void run_with_timeout(Func func, int timeout_seconds) { auto future std::async(std::launch::async, func); if (future.wait_for(std::chrono::seconds(timeout_seconds)) std::future_status::timeout) { FAIL() test timeout after timeout_seconds seconds; } }注意std::async超时后线程仍然在后台跑。如果超时的代码继续持有系统资源可能会泄漏。所以这个方案建议只用在那些你有信心的代码上而不是把所有测试都包一层。另外一个常见做法是给单个测试用例预设一个预期时间上限比如用std::chrono::steady_clock包一圈测试结束后打印耗时帮助发现性能退化。但它不能强制中断性能下降时你只是看到日志变慢测试照样通过。8.2 参数化测试一份数据测多种场景数据驱动的思路用在 C 测试里就是 GoogleTest 的参数化测试。写起来非常方便class CsvLineTest : public ::testing::TestWithParamstd::tuplestd::string, std::vectorstd::string { }; TEST_P(CsvLineTest, ParsesLineCorrectly) { const auto [input, expected] GetParam(); auto result parse_line(input); ASSERT_TRUE(result.ok); EXPECT_THAT(result.fields, ElementsAreArray(expected)); } INSTANTIATE_TEST_SUITE_P( CsvLineVariants, CsvLineTest, ::testing::Values( std::make_tuple(a,b,c, std::vectorstd::string{a, b, c}), std::make_tuple(a,\b,c\,d, std::vectorstd::string{a, b,c, d}), std::make_tuple(\\,a, std::vectorstd::string{, a}) ), [](const testing::TestParamInfoCsvLineTest::ParamType info) { return line_ std::to_string(info.index); } );参数化测试的价值在于新增用例时只需要往Values里加一行不需要复制整个测试函数。测试名字通过最后一个参数生成比如CsvLineVariants/CsvLineTest.ParsesLineCorrectly/line_0配合--gtest_filter可以精准筛选。使用 gtest 的INSTANTIATE_TEST_SUITE_P时注意模板参数里的std::vectorstd::string在Values里要显式写出来否则编译器会因为make_tuple推导类型不一致而报各种稀奇古怪的错。我在这个坑上花过不少时间。9. 我踩过的坑和一些一直遵守的规则9.1 测试代码就像产品代码一样要评审很多团队对测试代码的态度是“能跑就行”注释不写、命名随意、断言一股脑往上堆。但测试代码恰恰是项目里最需要认真对待的——它们要长期维护要随业务演进不断更新写得差的测试代码会在你改需求时变成最大的阻力。我的原则是测试代码要像产品代码一样遵循命名规范和代码风格。ParserTest.SplitsNormalCsvLine一看就知道在测什么ParserTest.Test1就是灾难。测试的意图要清晰一个测试只验证一个行为别在一个测试里又验证解析结果又验证文件路径处理。9.2 测试也要重构我从一个资深开发者那里学到一个习惯测试代码要定期重构。当你发现一个测试函数里塞了十几个步骤或者断言之间有明显的前置依赖就该停下来拆分了。测试代码重构和产品代码重构一样目标是让每个测试函数只做一件明确的事让失败信息能直接指向问题根因。比如我最初写的解析器测试T09 和 T05 的场景有重叠我在重构时把它们合并成了一个参数化测试反而更清晰。重构测试代码不是浪费时间是节约未来排查问题的时间。9.3 单测不是万能的明确它不负责什么单测最适合验证纯逻辑、算法、解析规则最不适合验证跨系统集成、真实环境下的性能、UI 交互。我在 C 项目的测试策略里除了单测还会配合集成测试集成测试跑在专门的测试环境里有真实的依赖服务。很多人把单测奉为圭臬把所有逻辑都往单测里塞结果是测试维护成本爆炸。我的原则是被测代码越接近纯函数单测的价值越大越接近系统边界单测的成本越高越应该下沉到集成测试。parse_line这种纯函数就是单测的最佳对象。它不碰外部 IO输入输出明确边界清晰。如果你在写一个网络框架那就该考虑把网络状态机单独抽出来测逻辑而把实际的收发字节交给集成测试。9.4 最后的建议从今天开始给新代码写测试如果你现在才开始接触 C 单元测试我的建议是从新代码开始不要试图给旧代码大规模补测试。旧代码没测试是有原因的——它们的架构往往不适合单测。强行补测你会发现大量的依赖无法隔离mock 建了一堆但断言没有意义。我的实际操作步骤是每写一个新的类或函数先想清楚输入输出和边界条件然后按正常路径 空输入 异常输入 特殊值四类场景先列测试用例再把逻辑写出来让测试跑过。测试驱动开发不需要落实到“先写断言再写实现”这么严格的流程但“测试用例先行”的思想是通用的。如果你已经有一个比较老的 C 项目那么从小模块开始把这篇文章里的 CMake 集成方式用起来跑通第一个测试再慢慢把关键模块的测试补上。你不需要一步到位但需要今天就开始。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑