简介:CppUTest是C/C++生态中主流的单元测试与模拟框架,专为测试驱动开发(TDD)设计,内置内存泄漏检测和Mock支持,可有效降低回归风险,尤其适用于嵌入式、IoT、后端服务等对稳定性要求较高的项目。这份源码压缩包共431个文件、大小约675KB,除大量cpp、h源文件外,还包含sh、cmake、m4等跨平台构建脚本,以及txt、readme等说明文档和各类IDE工程配置,可直接查阅、编译或集成到现有工程。目前已有702人学习下载,适合正着手搭建C/C++测试环境、或希望研究成熟测试框架内部机制的开发者,无论初学者还是资深工程师都能从中获得实用参考。通过阅读源码中的测试样例和Mock模块,可以学习到CppUTest的扩展点、内存泄漏宏配置方法及多平台交叉编译细节,从而在实际项目中快速落地,减少反复试错成本。 做嵌入式开发这些年,我最怕的就是有人跟我说:“这段逻辑你改一下,别改坏了。”改坏了不可怕,可怕的是你根本不知道哪儿坏了。串口打印、在线调试、瞪眼Code Review,碰上复杂状态机或者层层嵌套的协议解析,效率低得让人想摔键盘。后来在项目里正式引入 CppUTest,直接在PC上把核心逻辑全都测了一遍,心里那块石头才算落地。
CppUTest是一个面向 C/C++ 的轻量级单元测试框架,同时自带模拟库 CppUMock。它对嵌入式、驱动、协议栈这类硬件依赖很强的项目尤其友好,而且内置了内存泄漏检测器,省了不少排查内存问题的功夫。如果你正在给 C/C++ 项目选测试框架,又不想被 GoogleTest 那一堆依赖、模板概念和编译时间牵着走,这篇文章值得看一看。下面我会从选型理由、环境搭建、CppUMock用法、嵌入式落地方式到常见坑位,一条线讲完。
1. 为什么一个常写驱动的人会选CppUTest
早些年我做单元测试,首选是 GoogleTest,但它对嵌入式场景并不算友好。不是说不能用,而是你得面对一个很现实的问题:很多交叉编译工具链的C++标准支持是滞后的,GoogleTest 又比较重,经常会出现“测试框架先编译不过”的尴尬局面。当时一起评估的还有 Unity、Ceedling、Catch2,最后才被 CppUTest 的轻量设计打动。
我给几个主流框架画过一张粗糙的对比表,大概长这样:
| 框架 | 语言支持 | 依赖复杂度 | 内存泄漏检测 | 模拟支持 | 嵌入式适配 |
|---|---|---|---|---|---|
| CppUTest | C/C++ | 很轻,仅依赖C++标准库 | 内置 | CppUMock,随框架一起发布 | 非常好 |
| GoogleTest | C++ | 较重,需要cmake/ABI匹配 | 第三方工具配合 | 需要引入GoogleMock | 一般 |
| Unity | C | 很轻 | 无 | 需要配合CMock | 好 |
| Catch2 | C++ | 头文件为主,但依赖较重 | 无 | 无原生模拟 | 一般 |
选型这件事,最忌只看“哪个最流行”。CppUTest 的定位很明确,它就是冲着嵌入式、跨编译器、低负担来的。它的核心测试运行器没有花哨的宏魔法,编译产物就两个静态库:libCppUTest 和 libCppUTestExt。前者是测试框架本体,后者就是 CppUMock。你把它们丢进任何支持 C++11 的编译环境里,几乎都能编过。
再说一个我个人的体验:CppUTest 的失败信息比很多框架更贴近“定位问题”这个需求。比如断言失败时,它会明确指出测试文件、行号、期望值和实际值,还能配合内存泄漏定位输出分配点。这个细节平时不显眼,但真排查起问题来,能省下不少命。
2. 从克隆代码到跑通第一个测试
2.1 源码编译:两种方式都试过
CppUTest 官方提供 GitHub 仓库,源码编译是推荐方式。我习惯用 CMake,流程很简单:
git clone https://github.com/cpputest/cpputest.git cd cpputest mkdir build && cd build cmake .. -DCMAKE_BUILD_TYPE=Debug make -j8编译完成后,build 目录下会生成libCppUTest.a和libCppUTestExt.a两个静态库。如果你不喜欢 CMake,仓库根目录还有 autotools 那套,执行./configure && make也能出同样结果。不同点在于 CMake 对 Windows、Mac、Linux 的适配更省心。
编译时记得用 Debug 模式。Release 模式会关闭测试内部的很多安全检查,某些内存泄漏可能不会被捕获,等于把框架最重要的功能给废了。
2.2 一个最普通的测试用例长什么样
CppUTest 的测试结构非常直白。先写一个被测函数,比如拿最经典的加法来说:
// calc.h #ifndef CALC_H #define CALC_H int add(int a, int b); #endif // calc.cpp #include "calc.h" int add(int a, int b) { return a + b; }对应的测试文件:
// test_calc.cpp #include "CppUTest/TestHarness.h" #include "calc.h" TEST_GROUP(CalcTestGroup) { void setup() override { } void teardown() override { } }; TEST(CalcTestGroup, TestAddPositiveNumbers) { int result = add(2, 3); CHECK_EQUAL(5, result); }这里TEST_GROUP定义一个测试组,setup和teardown分别会在每个用例运行前和运行后调用,用来准备和清理测试环境。TEST里第一个参数是测试组名,第二个参数是用例名。断言方面最常用的是CHECK_EQUAL,它会比较两个值是否相等,失败时打印成“期望 5,实际 3”这类信息。
要注意一个细节:setup和teardown在 CppUTest 里不是强制要求的,你可以不写,但只要写了,就必须走setup() override/teardown() override这个语法。空测试组也合法,但太空的测试组通常说明你想偷懒,不如不写。
2.3 跑起来:测试运行器怎么接
写完用例还得有入口。最简单的做法是用官方提供的宏:
// main.cpp #include "CppUTest/CommandLineTestRunner.h" int main(int argc, char** argv) { return CommandLineTestRunner::RunAllTests(argc, argv); }编译的时候把测试源文件、被测源文件、CppUTest 静态库一起链接进去:
g++ -std=c++11 \ -I/path/to/cpputest/include \ test_calc.cpp calc.cpp main.cpp \ -L/path/to/cpputest/build \ -lCppUTest -lCppUTestExt \ -o run_tests跑一下./run_tests,你会看到类似这样的输出:
Test run: 1, Failures: 0, Errors: 0, Time: 0.001s这里我额外提醒一句:测试组名字和用例名字都要尽量工程化一点,比如TEST(FlashStorage, ReadWriteAcrossSectors)就比TEST(Test1, f1)直观得多。这一条在后期跑回归,特别是你一边改代码一边看测试输出时,价值会越来越大。
3. CppUMock:把外部依赖替掉,而不是哄着它跑
嵌入式项目里最常见的问题就是:代码依赖硬件。你测一个温度采集模块,它要调用readSensor(),这个函数需要真实传感器,但你在PC上做单元测试时根本不可能插着一个传感器。以前我见过有人用全局开关、#ifdef去切桩实现,代码里到处都是洞,维护起来像踩地雷。
CppUMock 就是干这个的。它让你以“设置预期行为”的方式来模拟依赖函数,比如“本次测试中readSensor应该被调用1次,参数是某个值,返回值是26”。
3.1 最简单的模拟调用
还是拿温度采集的例子。假设产品代码有一个对传感器读数的转换函数:
// sensor.h int readSensor(); int convertToCelsius(int raw);你想测试convertToCelsius的逻辑,但不想关心readSensor是怎么实现的,那就把readSensor模拟掉:
#include "CppUTest/TestHarness.h" #include "CppUTestExt/MockSupport.h" #include "sensor.h" TEST_GROUP(TemperatureTest) { void teardown() override { mock().clear(); } }; TEST(TemperatureTest, ConvertRawToCelsius) { mock().expectOneCall("readSensor").andReturnValue(200); int raw = readSensor(); int celsius = convertToCelsius(raw); CHECK_EQUAL(93, celsius); mock().checkExpectations(); }这里expectOneCall("readSensor")表示期望readSensor被调用一次,andReturnValue(200)则指定调用后返回200。接着你在被测代码里正常调用readSensor,测试框架会自动把这次调用“截住”,返回设定的值。
teardown里的mock().clear()很重要。它清空所有预期和实际记录,防止用例之间相互污染。不写这一行,测试多了以后会莫名其妙地出现“上次调用的期望没释放”这类问题。
3.2 带参数、引用输出、返回值一起控制
真实场景很少只有一个无参函数。CppUMock 支持带参数校验的模拟,比如一个温控模块要调用setFanSpeed(int speed),你可以在测试里这样声明:
mock().expectOneCall("setFanSpeed") .withParameter("speed", 3);这样setFanSpeed被调用时,CppUMock 会检查实参是否为3,不匹配会算测试失败。对于指针或内存参数,可以搭配withMemoryBufferParameter("buffer", data, size),对需要把结果写回调用方的输出参数,还有withOutputParameter系列。
反过来,如果模拟函数需要多个不同返回值,可以用andReturnValue配合thenReturnValue,或者直接用expectNCalls(3, "foo")期望连续调用若干次。写测试时别把预期设置得太死,也别太松,最好贴着真实调用路径来。
有一点容易被忽略:CppUMock 默认对调用顺序是敏感的。换句话说,真实代码里如果先调 A 再调 B,而你声明预期时先写了 B 再写 A,测试会失败。如果确实不需要关心顺序,可以在测试开头调用mock().disableStrictOrder()。我一般只在状态机切换这种对顺序极其敏感的场景才开严格顺序,其他情况关掉反而省心。
3.3 模拟 C 函数的两种思路
CppUMock 在 C++ 里使用很顺,但很多嵌入式项目是纯 C 代码。这时候有两条路。
一条是把 C 源文件编成 C++,用extern "C"包一下,然后在测试里直接用mock().expectOneCall("funcName")。因为 CppUMock 匹配的是调用点的函数名字符串,跟底层符号无关,纯 C 函数也能被模拟。
另一条是给被测代码提供一层薄薄的 C++ 包装。比如:
extern "C" int readSensor() { return mock().actualCall("readSensor").returnIntValueOrDefault(0); }把这个文件编进测试可执行文件,产品代码里的readSensor就会切到这份实现上,而不会去触碰真正硬件的驱动。两条路没有绝对好坏,关键看你的编译系统能不能把“测试用实现”和“产品实现”分开链接。
4. 嵌入式项目里的真实落地方法
4.1 宿主机先行,目标板后行
很多嵌入式团队对单元测试的第一反应是:我代码跑在 MCU 上,没法在PC上测。这个说法一半对一半错。没错,外部中断、寄存器操作、DMA 这类硬件行为确实没法完全在PC上复现,但协议解析、状态机、算法、校验和、消息队列这些纯逻辑部分,跟硬件基本没关系,完全可以拉到宿主机上编一遍跑一遍。
CppUTest 在宿主机上有先天的编译优势。它不依赖任何单板SDK,只要你有交叉编译链对应的宿主版本,比如用 x86_64 的 g++,就能把产品代码里的非硬件部分抽出来测试。这样做的好处是反馈速度极快——单条用例毫秒级跑完,改几个状态位、调一个边界条件,立刻就能看到结果。
4.2 隔离硬件依赖的三种姿态
如果产品代码直接调用了寄存器、硬件库函数,怎么办?我常用的手段有三种,按受欢迎程度从高到低排列:
- 接口层打桩:把硬件相关的操作封装成若干个函数接口,比如
FlashRead、FlashWrite。测试时,把这份接口实现换成一个用数组模拟的桩模块,跟 CppUMock 搭配也能做。 - 编译期切换:用
#ifdef UNIT_TEST在头文件里把REG_A这类宏重定向到模拟地址,或者直接把某几个底层函数替换成测试版本。 - 链接期替换:产品代码里并不直接依赖库函数,而是把真正驱动编成一个.a文件,测试工程的链接器让它优先选择测试桩对象文件。
第一种最推荐,因为它在代码结构上就强制你做了模块解耦,长期维护收益最高。第三种适合老项目,改动最小,但副作用是容易让你忽略接口设计问题。
严格来讲,CppUTest还自带一个叫MemoryLeakDetector的机制,会在测试退出时自动检查是否有未释放的内存。在做嵌入式代码的宿主机测试时,这个机制非常值钱,因为MCU上的malloc和PC上的行为差异很大,很多内存问题在MCU上很难复现,反而在PC上能被CppUTest捕捉到。
4.3 目标板上的测试怎么跑
有些人坚持要在目标板上跑单元测试。也不是不行,CppUTest 支持通过串口输出测试结果。你把测试框架和测试用例全部编进目标固件,跑完以后通过串口把结果发出来。但说实话,我没见过哪个团队把单元测试当主力放在真机上跑的,因为真机测试慢,还受调试器、硬件状态影响。真机测试更适合做集成测试和系统验证,单元测试的舞台就应该是宿主机。
如果你非要这么做,注意几点:目标板上的堆栈通常有限,CppUTest 默认的内存泄漏检测可能会带来额外开销,可以考虑在目标测试构建里关掉泄漏检测;另外,串口输出可能有字符丢失风险,测试结果解析要做好重传或校验。
5. 踩坑记录和几条提效技巧
5.1 内存泄漏误报:抓住的往往是全局静态对象
CppUTest 自带的内存泄漏检测器非常好用,但它也会误报。最常见的误报来源是全局静态对象,比如单例模式里的static Singleton instance;。这个对象生命周期贯穿整个程序运行,测试结束时它还没被销毁,检测器会认为这是泄漏。
我有一次排查了很久,最后发现是日志模块的静态缓冲在作怪。解决方案通常有两个:要么在测试运行器退出前显式销毁这些全局对象,要么针对特定测试禁用泄漏检测。禁用方式是通过命令行参数-p,它会让所有测试跳过内存泄漏检查。实际项目里我很少全局禁用,而是把漏报的测试单独分组,在 CI 脚本里单独处理。
5.2 不可重复执行的测试是最麻烦的
CppUTest 默认每个用例运行前都会执行setup,运行后执行teardown,但如果你在用例里写了不干净的清理代码,比如该free的指针没有free,下个用例就可能因为内存状态异常而莫名失败。这种失败最难查,因为报错位置经常是同一个测试组里的先后用例,看起来毫无关联。
我的习惯是:每个测试组里的teardown空着不代表没事,你要默认它“一定有对象需要清理”。尤其涉及 CppUMock 时,必须在teardown里调用mock().clear()。这比在用例末尾调用更能保证隔离性。
5.3 命令行参数能救命的几个
CppUTest 的命令行参数不多,但都很有用:
-g CalcTestGroup:只跑指定测试组。-n TestAddPositiveNumbers:只跑名字匹配的用例,可以跟-g组合使用。-sg:跳过指定测试组。-v:输出每个用例的详细结果,调试的时候打开很直观。-o junit.xml:输出 JUnit 格式的报告,可以直接接到 Jenkins、GitLab CI 等系统。
小技巧:开发过程中尽量用-g和-n组合定位单个用例,把跑整个测试集留给 CI。碰到随机失败时,再加-v去查看是哪个用例先挂的,通常能找到共享状态的泄漏点。
我在这套框架上折腾下来的整体感受是:CppUTest 不一定是最华丽的测试框架,但它绝对是在 C/C++ 和嵌入式这片苦海里最实用的那一类。你不用花太多时间在框架本身,就能把精力集中在真正该测的代码上,这一点对我来说,比什么都重要。
本文还有配套的精品资源,点击获取