C++ TDD实战:从红绿循环到测试框架与CMake搭建
发布时间:2026/10/17 15:15:08 锦皓数字建站

简介《C测试驱动开发实战指南》是Packt于2022年出版的一本面向C开发者系统学习TDD的PDF电子书作者Abdul Wahid Tanner结合自身近三十年C开发经验从测试驱动开发的基本流程讲起逐步引导读者在真实项目中实践“测试先行”从而降低缺陷、提升敏捷迭代质量内容设计兼顾新手与有经验开发者适合自学或团队共读。全书重点展示如何利用C20引入的概念concepts与模板特化技术设计可复用、易维护的单元测试库并深入讲解了日志库集成和服务测试的组织方式帮助读者建立从编写简单断言到测试复杂依赖的完整能力。作者还分享了一个实际案例用约650行代码自建轻量级单元测试库借此说明轻量测试工具如何满足日常测试需求对希望深入理解测试框架内部实现的读者尤有启发。资源仅含1个PDF文件压缩包约3.38MB内容紧凑、便于下载后通读或电子设备上查阅目前已有1399人学习/浏览适合借助TDD优化C工程实践的初学者和有经验开发者。1. C测试驱动开发实战指南先写测试再写代码到底搞定什么我接手过一个模拟项目X里面有一段日志解析逻辑历史久远改的人换了好几拨。某次我为了修一个内存越界在解析函数里加了个边界判断结果把隔壁模块的字符串处理也带崩了。查了两天才定位到根因——这段代码自打出生那天起就没有任何测试罩着。后来我在写新模块时强制自己走测试驱动开发TDD的红-绿-重构循环先写测试看它失败再写最少量的代码让它通过最后安全重构。效果很直接每周花在回归排查上的时间少了大半。这篇笔记就是一套可复现的落地路径从 C 测试框架选型、CMake 工程搭建到完整跑一轮红绿循环再到你十有八九会踩的坑。想看看 TDD 在 C 里到底怎么指导写代码、怎么保住老代码就往下读。2. 拆开 TDD 的三步循环红-绿-重构在 C 里每一步做什么2.1 红先让测试失败先写断言再写实现TDD 的第一步不是写实现而是写一个注定通过不了的测试。这个测试描述了你想让代码拥有的行为。在 C 里红可以是断言失败也可以是编译失败——两者都算数。关键是先让期望变得可执行否则你写出来的代码很容易变成顺手实现永远不知道对不对。举个最朴素的例子我要写一个计算字符串字节数的函数先只写测试// test_string_util.cpp #include gtest/gtest.h #include string_util.h TEST(StringUtilTest, SizeReturnsByteCount) { EXPECT_EQ(size_of(hello), 5u); }这时候 string_util.h 里根本没有 size_of 的声明编译直接报错。这是红而且是最常见的一种红。别急着跳过它——编译失败本身就在告诉你接口还没定或者定错了。先让这个错误存在再去设计接口。逻辑说明TEST宏定义一个测试用例第一个参数是测试套件名第二个是测试名两者组合成完整标识。EXPECT_EQ在这里做两件事比较两个值如果不等则记录失败但不终止当前测试。和ASSERT_EQ的区别是后者失败后会立即返回跳过后续断言。单测里优先用EXPECT_这样可以一个测试暴露多个问题。参数说明5u是 unsigned long literal因为 strlen 返回 size_t无符号。如果不写 u编译器会警告有符号/无符号比较在 -Werror 下直接编译失败。写测试时就要想好类型匹配这是一个很容易被忽略的细节。红阶段的目标不是跑通而是失败得有说服力。如果测试一次就过了说明它不是用来驱动实现的只是给已有代码贴标签那就不叫 TDD。2.2 绿用最少的代码让测试通过别急着优化看到测试红了才开始写实现。绿是让测试通过的那一步但规则是用刚好够用的代码不提前做任何扩展设计。给上面的测试写实现最少就是这样// string_util.h #pragma once #include cstddef std::size_t size_of(const char* s); // string_util.cpp #include string_util.h #include cstring std::size_t size_of(const char* s) { return strlen(s); }这里没有处理空指针没有优化循环没有改成模板。因为当前测试只要求 hello 返回 5strlen 已经满足。也许你立刻想到空指针会崩但那是下一个行为不是当前行为。TDD 里有个原则任何没有失败测试的代码都是多余的。为未来可能发生的崩溃提前加 if (s nullptr) 就是多余设计——它会引入一个你没验证过的分支而这个分支本身可能是错的。逻辑说明strlen 是 C 标准库函数返回 size_t对于以空字符结尾的字符串计算长度。这里用它而不是手写循环符合最少代码原则。当然如果你的项目禁用 C 运行时库那就另当别论。参数说明std::size_t是 C 里的无符号整数类型和size_t等价但std::前缀能避免全局命名空间污染。这里没有用std::string因为当前测试只需要 C 字符串接口后续需求变了你可能会改签名测试会替你把关。绿以后先跑一遍测试。通过后你才有资格做下一步。2.3 重构测试在手C 的坏味道才敢动重构是整个 TDD 里最容易被人忽略、也最值钱的阶段。红绿循环跑过一轮之后代码是能工作的但结构未必好。比如上面的size_of接收const char*调用方得先拿到 C 字符串才能调它。如果项目里到处是std::string这个接口就很别扭。可以在保持测试全绿的前提下把接口改成更现代的std::string_view// string_util.h #pragma once #include string_view std::size_t size_of(std::string_view s); // string_util.cpp #include string_util.h std::size_t size_of(std::string_view s) { return s.size(); }测试文件不变重新编译一遍测试仍然通过。这就叫重构行为不变结构变好。测试在这里扮演的角色是安全网——没有它你不敢动接口因为不知道谁在什么地方依赖了旧签名有了它你可以放心改编译器加测试会替你守住回归。参数说明std::string_view是 C17 引入的轻量只读字符串视图不拥有内存。从const char*改成它调用方可以传std::string、字符串字面量等隐式转换零拷贝。这个重构如果没有测试你需要手动检查所有调用点在大型项目里几乎不可能。注意一点重构也是拿测试当标尺但不是每个测试都要保留。那些绑定了内部实现细节的测试比如测试了一个 private 辅助函数重构时不但帮不上忙还会拖后腿。后面避坑章会细说。3. 在 C 工程里搭起 TDD 的最小环境框架选择与项目结构3.1 测试框架选型轻量断言 vs 完整测试框架C 没有内建测试框架所以第一步是选择。常见选项有 GoogleTest、Catch2、doctest。选型不是跟风而是看你项目现状。框架头文件方式断言风格学习成本适合场景GoogleTest源码编译TEST/EXPECT/ASSERT中中大型项目需要 mock 和参数化测试Catch2单头文件或模块TEST_CASE/SECTION低快速上手测试文件少doctest单头文件TEST_CASE/REQUIRE最低编译时间敏感测试代码量小我一般建议新工程首选 GoogleTest不是因为它最强而是因为它最主流通用。它的断言宏齐全测试执行器能报告每个失败的文件和行号还有死亡测试和 mock 支持。如果你项目里已经有现成的测试框架别因为这篇文章就换换来换去才是最大的坑。参数说明GoogleTest 需要链接 gtest 和 gtest_main 两个库。gtest_main 提供一个标准的 main 函数它会自动调用::testing::InitGoogleTest和RUN_ALL_TESTS。如果你自己写 main可以做更多初始化但大多数场景直接用 gtest_main 就够了。3.2 一个最小的 CMake 测试框架项目怎么组织有了框架下一步是让测试能编译、能跑而且不污染生产代码。我用 CMake 组织一个最小工程目录结构如下tdd_demo/ ├── CMakeLists.txt ├── string_util.h ├── string_util.cpp └── test/ └── test_string_util.cppCMakeLists.txt 这样写cmake_minimum_required(VERSION 3.16) project(tdd_demo CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 拉取 GoogleTest如果网络受限可换成本地路径 include(FetchContent) FetchContent_Declare(googletest URL https://github.com/google/googletest/archive/v1.14.0.tar.gz ) FetchContent_MakeAvailable(googletest) # 被测试的生产代码编译成库 add_library(string_util STATIC string_util.h string_util.cpp) target_include_directories(string_util PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) # 测试可执行文件 enable_testing() add_executable(string_util_test test/test_string_util.cpp) target_link_libraries(string_util_test PRIVATE string_util gtest_main) # 注册到 CTest add_test(NAME string_util_test COMMAND string_util_test)逻辑说明FetchContent_MakeAvailable会在首次运行 CMake 时下载并编译 GoogleTest。如果你的构建机没外网就把 URL 换成本地 tar 包路径或者用find_package(GTest)替代。生产代码编成静态库测试代码单独编成一个可执行文件这样生产代码里不会打包进任何测试符号。参数说明PUBLIC和PRIVATE是关键。target_include_directories用PUBLIC表示任何链接 string_util 的目标都能找到头文件target_link_libraries里gtest_main写PRIVATE意思是只有测试目标需要 gtest生产库不依赖它。这样严格分离后面避坑章会解释为什么。3.3 测试目标与生产目标分开编译的好处有人图省事把测试代码直接加到生产源文件里或者用同一个 add_executable 一起编。短期看省事长期看全是债。好处之一是编译隔离。测试代码引用了大量 gtest 头文件如果和生产代码混编任何一个测试文件改动都会让生产模块全量重编增量编译直接被废掉。好处之二生产库保持纯净。发布库时库里不能残留 gtest 的符号否则消费者必须额外安装 gtest 才能链接——这个后果极其糟糕。好处之三是职责清晰。谁依赖谁一目了然生产库不依赖测试测试依赖生产库。用 CTest 注册后跑全部测试只需要一句话cmake --build build --target string_util_test ctest --test-dir build --output-on-failure如果测试失败--output-on-failure会直接打印失败断言对应的源码行不用去翻日志。养成习惯每次提交前跑一遍你会发现未测试代码带来的阴影会慢慢退散。4. 用 TDD 写一个 C 模块从需求到红绿循环的完整步骤4.1 需求拆成可断言的行为清单现在我用一个真实感很强的例子把整个流程走一遍解析一行形如namealice的键值对字符串允许指定分隔符。函数签名初步定为bool parse_pair(const std::string line, char sep, std::string key, std::string value);返回 true 表示解析成功key 和 value 被填充返回 false 表示失败两个输出参数不应被信任。先把需求拆成行为清单后期每个行为对应一个或一组测试行为 1遇到namealice分隔符为返回 truekey 是namevalue 是alice。行为 2字符串里没有分隔符返回 false。行为 3分隔符出现在末尾比如namevalue 应该是空字符串仍然返回 true。行为 4分隔符出现多次比如abc取第一个分隔符key 是avalue 是bc。这些行为就是第一轮红绿的种子。注意行为 4 是我在写代码前临时拍板定的规则不是需求方给的。TDD 的好处就是逼你在这个时刻停下来确认而不是写实现时偷偷自己做决定。4.2 第一轮红绿先写接口和失败的测试新建test/test_parser.cpp写第一个测试#include gtest/gtest.h #include string_util.h TEST(ParserTest, SplitsSimplePair) { std::string k, v; EXPECT_TRUE(parse_pair(namealice, , k, v)); EXPECT_EQ(k, name); EXPECT_EQ(v, alice); }此时 string_util.h 里只有size_of没有parse_pair。编译会报错说函数未声明。这是红——而且是你想要的。这个错误精确到哪个头文件缺哪个声明比运行时崩溃好查得多。逻辑说明测试里用了EXPECT_TRUE和EXPECT_EQ。这里故意没有用ASSERT_TRUE,因为如果 parse_pair 返回 false后面两个EXPECT_EQ虽然会失败但至少能一并展示 k 和 v 的实际内容方便定位是返回值错了还是输出参数错了。4.3 让测试通过实现只够用不做额外设计在 string_util.cpp 里加实现bool parse_pair(const std::string line, char sep, std::string key, std::string value) { auto pos line.find(sep); if (pos std::string::npos) { return false; } key line.substr(0, pos); value line.substr(pos 1); return true; }跑测试SplitsSimplePair 通过。逻辑说明std::string::find返回第一次出现 sep 的位置找不到时返回std::string::npos。这里取第一个分隔符恰好和我之前拍板的规则一致。substr 从位置 pos 截取 key从 pos1 截取 value。这个实现完全符合当前测试要求没有处理分隔符在首位比如alice的情况因为行为清单里没有。参数说明line.find(sep)中 sep 是 charstring 重载了接受 char 的 find 版本。如果你传的是std::string会触发另一个重载注意别混用。std::string::npos是 size_t 最大值无符号数比较时要小心不要把它赋给有符号变量后再比较。4.4 边界与异常补第二轮测试第一轮绿之后把行为清单剩余项写成测试TEST(ParserTest, RejectsLineWithoutSeparator) { std::string k, v; EXPECT_FALSE(parse_pair(hello, , k, v)); } TEST(ParserTest, HandlesEmptyValue) { std::string k, v; EXPECT_TRUE(parse_pair(name, , k, v)); EXPECT_TRUE(v.empty()); } TEST(ParserTest, UesesFirstSeparatorWhenMultiple) { std::string k, v; EXPECT_TRUE(parse_pair(abc, , k, v)); EXPECT_EQ(k, a); EXPECT_EQ(v, bc); }逐个跑。HandlesEmptyValue 和 UesesFirstSeparatorWhenMultiple 应该直接通过因为现有实现就是这么做的。RejectsLineWithoutSeparator 也通过。三个测试全绿没有新实现代码。这里要说明的是边界测试不是只为了验证现有实现而是为了把规则固定下来。比如你日后想改成取最后一个分隔符这几个测试会立刻让abc那个用例变红逼你重新审视需求。TDD 里每个测试都是一份可执行的需求文档。如果某个测试红了不要立刻改实现先问自己是需求规则错了还是实现错了某个需求点没有定义清楚测试写得不合理也会红。这时调整测试本身也是合法的但要保证调整后的测试仍然真实反映了业务需求而不是为了让代码通过而弱化断言。5. C TDD 避坑指南常见问题与排查方法5.1 测试与实现同文件编译导致生产代码被测试污染现象你写了一个test.cpp里面直接#include string_util.cpp图省事不用链接库。编译通过测试也能跑但后来你发现发布给客户的静态库里出现了testing::Test相关的符号客户链接时告诉你有未定义引用。原因测试代码和生产代码编进了同一个目标gtest 的头文件和宏被展开后进入了生产对象。解决严格分离目标生产代码编成库测试代码单独编成可执行文件并链接 gtest_main。在第 3.2 节的 CMake 结构下不会出现这个问题。如果你发现库文件特别大或者里面有测试相关符号立刻检查是不是有人在 add_library 里传了测试源文件。5.2 断言太粗覆盖率虚高现象测试只调了函数没检查调用结果。代码覆盖率报告显示行覆盖率 80%但你把实现里核心逻辑的return false改成return true测试还是全绿。原因写测试的目标是覆盖而不是验证行为很多人为了凑覆盖率只写了能执行的代码路径没给断言。解决每个行为至少一个断言尤其是关键副作用。比如 parse_pair 测试里如果只写parse_pair(...)不检查 k 和 v那这个测试等于没写。覆盖率数字只能说明这段代码执行过不能说明执行得对。用一个小技巧自查把某个实现分支故意改成错误的看看测试是否会变红。如果不变红说明那个分支没有被任何断言保护。5.3 测试写得太细重构被测试绑架现象你重构了一个内部私有函数的调用方式结果十来个测试跟着改改完一次可以改第二次就想删测试。原因测试直接断言了内部实现细节比如调用了哪个私有成员函数或者验证了某个中间缓存的大小。这些测试对用户可见行为没有意义只会拖住重构的手脚。解决测试尽量面向公共接口和可观察行为。私有函数不单独写测试通过公共接口的行为间接验证。如果某段私有逻辑确实复杂可以把它抽成独立组件用另一个公共接口去测它。判断标准修改私有实现时如果测试需要同步修改那测试要么重写要么删掉。测试应该和实现解耦绑定的是契约不是结构。5.4 依赖外部资源文件、网络、时间导致测试不稳定现象测试里读写固定路径的临时文件比如/tmp/test.conf。第一次跑通过第二次跑失败因为你上一次测试留下的残留文件影响了断言。或者测试里访问某个网络接口今天通过明天超时。原因测试依赖了外部状态外部状态是会漂移的。TDD 的红绿循环要求测试结果可复现不可复现的测试比没有测试更糟因为它会消耗你的信任。解决外部资源通过依赖注入替换成内存实现。比如文件操作抽象成接口测试用内存 buffer 实现时间相关的函数传入参数不要直接 sleep。下面给一个最小脱敏示例把随机时间相关逻辑摘出去// 原来代码里直接调 system_clock测试里不好控制 // 改成接收时间点作为参数 bool is_expired(const std::time_t now, const Item item) { return now item.expire_at; }测试时传入一个写死的时间点生产代码传std::time(nullptr)就行。这不需要引入任何 mock 框架手动控制依赖边界即可。6. 从单测到集成TDD 在 C 项目里的落地技巧6.1 用测试替身隔离外部依赖当被测对象依赖数据库、文件系统或硬件时直接让真实依赖参与测试会拖慢且不稳定。常见做法是给外部依赖定义接口测试里写一个最小实现。比如某模块需要读配置定义class ConfigSource { public: virtual std::string get(const std::string key) 0; }生产代码用FileConfigSource测试里用StubConfigSource直接返回固定字符串。这比引入重量级 mock 库更直观也更容易维护。6.2 覆盖率报告怎么用别被数字骗了用cmake --build build --target coverage或 gcov/lcov 工具可以生成覆盖率报告。但我的建议是只看趋势不设硬性线上指标。比如新代码提交时如果新增逻辑没有对应测试用 diff 覆盖率卡住它而不要拿着整个项目的行覆盖率做奖惩。因为行覆盖率考量的只是执行路径不能证明断言的有效性。覆盖率类型说明适用场景行覆盖率代码行是否被执行快速看遗漏文件分支覆盖率if/switch 各分支是否都走逻辑复杂模块变异测试故意改错代码看测试能否识别高质量测试团队变异测试是更强的手段但成本高。你的工程如果刚开始搞 TDD先做行覆盖率和分支覆盖率就够了。6.3 把测试跑进提交钩子让 TDD 成为习惯TDD 最大的敌人不是不会写而是懒。人都会在 deadline 前找借口跳过测试。把测试跑进 pre-commit 钩子等于把纪律交给机器。写一个极简脚本#!/bin/sh # .git/hooks/pre-commit cmake --build build --target string_util_test || exit 1 ./build/string_util_test || exit 1脚本功能先编译测试目标再运行测试。任何一个失败提交直接中止。注意|| exit 1的写法前面命令失败钩子就返回非零git 会拒绝提交。这里的 target 名称应该和你实际工程里的对起来多个测试目标可以用ctest代替。这是个习惯问题。刚开始会觉得烦每次提交多等几十秒。但过了两周你就会发现前端时间那些再也不敢动某段代码的想法少了重构也变得大胆了。我个人的教训是别把 TDD 当成一套圣旨它不是让你每行代码都先写测试。有些一次性脚本、原型验证代码根本不值得测试。但凡是以后要维护三个月以上的模块必须走红绿循环。哪怕只是修一个 bug也先写一个能复现这个 bug 的失败测试然后看着它变绿。这个习惯救过我很多次也希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。