在 C 语言项目里做单元测试,试过的人都知道那种别扭劲儿:一个main函数里堆几十个断言,跑挂了还得自己数行号;想用assert又觉得太寒碜,失败信息连个值都不打印;好不容易引入个测试框架,一看文档,依赖库哗啦啦拉进来一堆,编译配置比被测试的代码还复杂。嵌入式场景更惨,交叉编译链一换,框架自带的某些 POSIX 调用直接罢工。
这其实是 C 生态的老问题了——测试框架要么功能全但太重,要么轻便但只够自娱自乐。直到我碰到一个叫caveman的项目,它几乎是为"C 语言单元测试"这个需求量身定做的极简方案:整个框架就一个头文件,核心机制全是宏,没有任何外部依赖,编译出来的测试代码小到可以忽略不计。对嵌入式开发、驱动验证、库函数自测这类场景来说,输出一个带颜色的通过/失败报告,定位到具体的测试名和表达式,这就够了。
这篇文章就围绕 caveman 展开,从它的设计思路、断言宏的核心机制,到怎么集成进真实项目、踩过哪些坑,一条条捋清楚。我自己也是从 "printf 调试法" 转型过来的,知道大家最想要的是什么:开箱即用、看得懂原理、出了问题知道去哪儿排查。下面内容适合所有写 C/C++ 的开发者,尤其是嵌入式方向和基础库维护者。
1. Caveman 是什么:一个刻意保持"原始"的测试框架
1.1 项目背景与核心理念
Caveman 这个名字本身就带着一种姿态——穴居人,原始、简单、直来直去。它不想做重武器,只想给你一根削尖的木棍,够用就行。项目最初的设计目标非常明确:用最少的代码提供 C 语言单元测试最核心的能力——断言、测试用例定义、失败统计。
整个框架的实现思路是围绕宏展开的。宏在 C 里经常被人诟病,但在这个场景下恰恰是杀器:宏可以在调用点直接展开,捕获__LINE__、__FILE__、__func__这些编译期信息,还能在表达式求值时保持上下文。如果改成普通函数,你得传字符串、传行号、传表达式文本,API 会变得又臭又长。Caveman 用宏把这一切封装掉了,使用者只写assert_that(actual, is(expected))这种接近自然语的断言,编译期自动把位置信息带出来。
实际使用中,caveman 项目需要两个文件:一个核心头文件(定义断言和测试宏),一个简单实现文件(统计结果、打印报告)。依赖项为零,连标准库都只需要一点点字符串和输出函数。这意味着你可以把它直接扔进任意 C/C++ 工程,甚至交叉编译到单片机上跑。
1.2 它的适用场景与定位
Caveman 这类极简框架最舒服的场景是:
- 嵌入式 / 固件开发:交叉编译环境很敏感,框架依赖越少越好。caveman 不依赖 fork、不依赖动态库,纯宏和纯函数调用,在裸机环境也能跑。
- 算法库和数据结构库的自测:比如写了个环形缓冲区、哈希表、状态机,需要快速验证正确性,不想为了一个测试引入庞大的测试框架。
- 教学演示:给学生讲 TDD(测试驱动开发)时,caveman 的代码量足够小,可以直接打开源码看断言是怎么实现的,学习成本极低。
- 咱们这些习惯了 printf 调试的老油条:想迈入正规单元测试,但又怕被框架的学习曲线劝退,caveman 几乎可以零成本上手。
反之,如果你的需求是 mock 系统调用、需要复杂的测试套件嵌套、动态注册用例、并发测试,那 caveman 确实不适合——它不是干那个的。工具选型讲究匹配,原始人有原始人的活儿,现代人有现代人的活儿。
2. 核心机制拆解:藏在宏背后的设计巧思
2.1 Test 宏与测试用例的声明周期
Caveman 最核心的宏是Test(name)。用法如下:
Test(CanAddTwoNumbers) { assert_that(1 + 1, is(2)); }这行宏展开后,本质上生成一个void test_name()函数,并把这个函数指针注册到一个静态列表里。就这个行为,我想展开聊几句,因为理解它,你才知道 caveman 的"注册机制"有多妙。
一般的测试框架会用动态数组维护用例列表,caveman 用了一种更取巧的方式:利用静态初始化往链表里挂节点。大概套路是这样:
typedef struct TestFunc { void (*func)(void); const char *name; struct TestFunc *next; } TestFunc; #define Test(name) \ static void test_##name(void); \ static TestFunc test_node_##name = { test_##name, #name, NULL }; \ static void test_##name(void)再配合一个"构造函数"或者使用者手动调用的注册入口,把test_node_##name挂进全局链表。这样每个测试用例天然是独立的static函数,外部访问不到,避免命名冲突。我在实际项目里经常写上百个测试用例,用这种宏展开方式,从没遇到过用例函数名相互覆盖的问题。
测试用例的执行顺序,就取决于注册顺序,也就是你Run_Tests()之前有多少Test宏被展开。这也提醒了一件事:测试用例之间不要假设执行顺序,更不要依赖全局状态。后面我会讲这个坑。
2.2 assert_that 与断言链的设计
Caveman 的断言语法是assert_that(actual, is(expected)),这正是它 API 最具辨识度的地方。assert_that接收两个参数,第一个是"被测值",第二个是"匹配器表达式"。匹配器不只是is,还包含is_not、is_true、is_false、is_null等。
这里的设计精髓在于:断言失败的时候,宏会在预处理和编译期自动把两个表达式的源码文本拼进错误消息里。比如:
assert_that(result, is(42));如果result实际是 30,输出大致是:
test.c:12: error: expected 42 but got 30因为宏内部用了#expected和#actual字符串化操作。
我自己写测试的时候,特别喜欢这种失败信息——一条错误直接告诉我:哪个文件哪一行、期望是什么、实际是什么。对比裸assert,它的输出只有Assertion failed: result == 42 in file test.c line 12,还得自己开调试器看变量,效率完全不在一个层级。
还有一个细节值得注意:assert_that内部对actual和expected的求值顺序、求值次数是有讲究的。好的实现会保证每个参数只求值一次,避免类似assert_that(i++, is(1))这种带副作用的断言出现双重递增的诡异问题。如果框架对表达式求值两次,副作用断言会造成极难排查的 bug。我印象中,caveman 的实现是遵循"只求值一次"原则的,这也是我在项目里敢在断言里塞函数调用的原因。
2.3 Run_Tests 与结果统计的幕后逻辑
所有用例定义完之后,入口需要一个类似这样的调用:
Run_Tests();它会遍历注册链表,逐个执行测试函数,记录通过数和失败数,最后打印一份汇总报告。失败时,框架应该返回非零状态码,方便 CI 系统识别。
让我们看看它的输出形态:
Running 5 test(s)... [ PASS ] test_CanAddTwoNumbers [ FAIL ] test_CanDivideByZero test.c:42: expected 0 but got -1 [ PASS ] test_CanSubtract ... 5 test(s) run: 4 passed, 1 failed就这一段输出,对日常开发完全够用。红绿一眼分辨,失败信息直达行号。我经常把这种输出直接对接 Jenkins 或 GitLab CI 的日志,方便得很。
统计部分实现也不复杂,就是一组全局计数器。值得提醒的是:如果测试过程本身触发了段错误(比如空指针解引用),进程直接崩溃,计数器根本来不及打印。这个时候别怪框架,这是测试用例自己的问题,需要用调试器跑。这部分我会在常见问题里详细说。
3. 实操:把 Caveman 集成到你的项目里
3.1 环境准备与最小集成步骤
假定你已经有一个 C 项目,用的是 Makefile 或者 CMake。集成 caveman 的步骤非常简单,我以 Linux 下的 CMake 工程为例说明。
第一步,把 caveman 的两个源文件(比如caveman.h和caveman.c)放到你的third_party/caveman目录下。
第二步,在CMakeLists.txt里增加一个测试可执行文件的构建目标:
add_executable(unit_tests test/test_main.c test/test_ringbuffer.c third_party/caveman/caveman.c ) target_include_directories(unit_tests PRIVATE third_party/caveman) target_link_libraries(unit_tests PRIVATE your_project_lib) add_test(NAME unit_tests COMMAND unit_tests)第三步,创建一个测试入口文件test/test_main.c:
#include <caveman.h> int main(void) { Run_Tests(); return 0; }第四步,在其他测试文件里写用例,并在编译单元里声明引用:
#include <caveman.h> Test(RingBuffer_InitSetsLengthToZero) { RingBuffer rb; ringbuffer_init(&rb); assert_that(rb.length, is(0)); }你没有看错,就是这么简单。没有额外的初始化函数,没有继承和 suite 类的东西。每个Test宏展开的函数会被注册,链接进unit_tests可执行文件,最后由main里的Run_Tests()统一跑起来。
3.2 一个真实完整的例子:测试环形缓冲区
把上面的片段串起来,我拿一个简单的环形缓冲区模块做例子,展示完整的测试文件长什么样。
被测试的模块ringbuffer.h / ringbuffer.c实现大致如下:
// ringbuffer.h typedef struct { int buf[16]; int head; int tail; int length; } RingBuffer; void ringbuffer_init(RingBuffer *rb); int ringbuffer_push(RingBuffer *rb, int value); int ringbuffer_pop(RingBuffer *rb, int *value);测试文件test/test_ringbuffer.c:
#include <caveman.h> #include "ringbuffer.h" Test(RingBuffer_InitSetsDefaults) { RingBuffer rb; ringbuffer_init(&rb); assert_that(rb.length, is(0)); assert_that(rb.head, is(0)); assert_that(rb.tail, is(0)); } Test(RingBuffer_PushAndPopWorkInFifoOrder) { RingBuffer rb; int val = 0; ringbuffer_init(&rb); ringbuffer_push(&rb, 10); ringbuffer_push(&rb, 20); assert_that(ringbuffer_pop(&rb, &val), is(1)); assert_that(val, is(10)); assert_that(ringbuffer_pop(&rb, &val), is(1)); assert_that(val, is(20)); } Test(RingBuffer_RejectsPushWhenFull) { RingBuffer rb; ringbuffer_init(&rb); for (int i = 0; i < 16; i++) { ringbuffer_push(&rb, i); } assert_that(ringbuffer_push(&rb, 999), is(0)); assert_that(rb.length, is(16)); } Test(RingBuffer_PopOnEmptyReturnsError) { RingBuffer rb; int val = 0; ringbuffer_init(&rb); assert_that(ringbuffer_pop(&rb, &val), is(0)); }编译运行后,终端输出四行 PASS 或 FAIL,配合一条失败时的具体表达式信息,整个验证过程干净利落。
这里我要分享一条实操经验:测试文件尽量按模块拆分,每个模块一个.c文件,而不是堆在一个test_all.c里。caveman 的宏注册机制允许你把不同文件里的Test宏都挂到同一个链表中,所以拆文件不影响执行。拆开的好处是修改一个模块的测试时,重编译范围最小,CI 的缓存命中率也高。
3.3 与构建系统和 CI 流水线结合
只要测试可执行文件返回 0,CI 就认为通过。caveman 的Run_Tests()会统计失败数量,如果有失败用例,返回非零。
在 Makefile 场景下,写法也直白:
test: unit_tests ./unit_tests如果想让每次make都自动跑测试,就把test加入默认目标:
all: build test在 CI 脚本里,比如 GitHub Actions,你只需要加一步:
- name: Run unit tests run: make testCaveman 在这条链路里几乎不需要额外配置,因为它就是"一个可执行文件、返回状态码、输出文本"。对比有些框架需要生成 XML 报告、需要配置测试发现插件,caveman 完全可以靠最朴素的日志解析来对接。
有一点想提醒:如果你在嵌入式环境做交叉编译,Run_Tests()跑在一台开发板上,输出走串口,同样是纯文本,照样能用。只要把串口日志拉出来 grep 一下[ FAIL ]就够了。
4. 常见问题与排查技巧实录
4.1 断言失败却不显示具体值
我遇到最困惑的情况是:断言失败后,日志只显示expected ... but got ...,但括号里的两个表达式文本都在,就是看不到具体的值长什么样。排查下来,多半是宏的字符串化参数没有正确求值。比如:
assert_that(get_status(), is(STATUS_OK));如果STATUS_OK是一个枚举常量,表达式文本在宏里被字符串化没问题,但如果get_status()返回的是结构体或指针,默认的%d格式化就不一定对得上。遇到这种情况,最简单的方法是在断言前把值拆出来:
int status = get_status(); assert_that(status, is(STATUS_OK));这也顺手解决了求值次数和调试断点的问题。Caveman 的核心是轻量,你不能指望它对复杂类型做深度打印。
4.2 测试函数之间的状态污染
Caveman 的Test宏展开出来的都是普通函数,执行顺序依赖注册顺序。如果你的代码里有静态全局变量、缓存、单例,前一个测试改了状态没还原,后一个测试就直接被带偏。
我踩过最典型的坑是:被测模块里有个静态全局的错误码标志,第一个用例设计期望"某个失败操作会置位标志",测完没清,第二个用例一进来对标志做断言,就直接失败了。
解决方案是:在每个测试里手动构造干净的初始状态,不要在用例之间依赖"上一个用例的清理"。更推荐的做法是给被测模块写一个module_reset()之类的辅助函数,专门用于测试复位。虽然 caveman 本身不提供 setup/teardown 钩子,但你在每个Test函数的第一行调用复位逻辑,效果是一样的。这个"原始"的代价换来的是显式、可控,调试时你不会迷惑于框架偷偷做了哪些清理。
4.3 浮点数比较与字符串比较的坑
直接拿assert_that(0.1 + 0.2, is(0.3))写断言,几乎必挂无疑。这是所有直接做等值比较的框架的通病,不只是 caveman。浮点运算有精度误差,你需要的往往是一个误差范围:
assert_that(fabs(result - 0.3) < 0.0001, is_true);这行代码的意思是:误差小于 1e-4,就算测试通过。虽然把表达式写在断言参数里没有像is(0.3)那样简洁,但胜在明确。如果项目里浮点比较多,建议封装一条辅助宏:
#define assert_double_almost_equal(actual, expected, epsilon) \ assert_that(fabs((actual) - (expected)) < (epsilon), is_true)字符串比较同理,别用assert_that(str, is("hello"))指望它帮你做strcmp。指针比较会直接拿两个地址做比较,基本必挂。正确姿势是:
assert_that(strcmp(str, "hello") == 0, is_true);本质问题在于is匹配器比较的是==,它对 C 字符串不友好。知道这一点,你就不慌。
4.4 测试进程段错误或崩溃怎么办
如果你的测试用例把空指针解引用了,或者数组越界写坏了栈,进程可能在断言还没打印时就崩溃了。这时你不会看到[ FAIL ],只会看到Segmentation fault。
别急,这是最容易排查的情况,因为 caveman 的用例函数都是普通函数,直接用 GDB 跑:
gdb ./unit_tests (gdb) run (gdb) bt崩溃时的调用栈会指到具体的测试函数,你再用 GDB 打印局部变量,问题往往当场现形。还有一种情况:崩溃发生在Run_Tests()之前的全局构造阶段,那就要检查是否有全局变量的初始化逻辑在搞鬼。
我的实操经验是:每写三五个用例就编译跑一次,不要攒一大把用例再统一调。崩了的时候,二分注释用例,快速锁定是哪个用例引起的,比面对一大堆代码瞎猜要省太多时间。
4.5 断言宏里带副作用的表达式
这一点值得单独提:assert_that(i++, is(1))这类写法非常不建议。虽然框架理论上保证参数只求值一次,但宏展开链复杂的时候,你很难验证到底求值几次。就算这次版本求值一次,下次升级框架也可能改变行为。这类问题一旦出现,属于"随缘复现",非常难定位。
正确的做法永远是先算出来,再断言:
int old_value = counter->value; int new_value = increment_and_get(counter); assert_that(new_value, is(old_value + 1));测试的目的本来就是验证行为,不是为了炫技写花哨表达式。把被测调用单独提出来,还能顺便在调试器里查看返回值,一举两得。
5. 横向对比与选型建议
5.1 Caveman、Check、Unity、CppUTest 的差异
很多人在选 C 语言测试框架时都纠结过。这里我用表格把几个主流框架的核心差异列一下,方便做决策。
| 框架 | 语言 | 依赖 | 断言风格 | 特色 | 适用场景 |
|---|---|---|---|---|---|
| Caveman | C | 无 | assert_that(x, is(y)) | 极简单头文件 | 嵌入式、小型库、教学 |
| Check | C | 动态库 | ck_assert_int_eq等 | 支持 fork 隔离用例 | 大型桌面/服务端项目 |
| Unity | C | 无 | TEST_ASSERT_EQUAL_INT | 专为嵌入式设计 | 单片机、固件测试 |
| CppUTest | C/C++ | C++98 | CHECK_EQUAL | 支持 mock、内存泄漏检测 | C++ 项目、大型测试套件 |
Check 的 fork 模式很诱人:每个用例独立进程,崩溃也影响不到其他用例。但代价是平台依赖性强,交叉编译时往往跑不起来。
Unity 和 Caveman 很像,都是嵌入式向的轻量级框架,区别在于 Unity 提供了更多内置断言宏(比如精确断言位、内存块相等),而且有配套的测试生成工具。如果你主要在嵌入式上做深度的寄存器级验证,Unity 上限更高;如果只是想快速给算法库加一层保护网,Caveman 更顺手。
CppUTest 是 C++ 世界的老牌框架,功能最全,mock 和内存检测都很香。但它对纯 C 项目的纯 C 编译环境不友好,引入它往往意味着让代码向 C++ 靠拢,这是一个大决策。
5.2 什么情况下闭眼选 Caveman
我个人的选型原则很简单:
- 如果你的代码是纯 C,或者以 C 为主,并且测试环境经常切换(比如本地 x86 编译,CI 里用 ARM 交叉编译链),首选 Caveman
- 如果被测模块是"纯函数式"的库,比如数据结构、加密算法、协议编解码、数学函数,不涉及文件 IO 或系统调用的 mock,Caveman 完全够用
- 如果团队里新手多,想让大家先养成写测试的习惯,拿 Caveman 当入门工具再好不过——代码本身都能当阅读材料
- 如果编译环境极其苛刻,比如某种商业编译器的评估板 SDK,能塞进去的东西少得可怜,Caveman 的单文件形态可以省去很多麻烦
5.3 什么情况别用 Caveman
别误会,Caveman 不是万金油。当你遇到这些场景,老老实实上重型框架:
- 需要 mock 文件系统、socket 等系统调用,Check/CppUTest 有针对性的 mock 工具链
- 测试用例需要参数化,从配置文件或者命令行动态生成用例,Caveman 的静态注册机制搞不定
- 项目已经有完整的测试基建设施(比如自动解析 JUnit XML 报告),而 Caveman 只输出纯文本,接入成本反而变高
- 测试覆盖要求特别高,需要框架提供覆盖率采集的钩子,这时候直接换 gcov/lcov 配套的测试框架更合适
工具选型不要迷信名气,也不要鄙视原始。Caveman 这种框架的价值恰恰在于它不做什么,它只做一件事并且用最简单的方式做——这一点,很多大而全的框架反而做不到。
写在最后:我对 Caveman 的整体感受
用 Caveman 写了几个项目的测试用例后,我最明显的感觉是:写测试的阻力变小了。以前用一些重型框架,光是想清楚"用例怎么注册、套件怎么分层、配置文件怎么写"就要折腾半天,写着写着就不想写了。Caveman 把一切都摊在台面上,头文件打开就能看懂,使用的时候你只需要关心一个问题:被测逻辑到底对不对。
最后分享一个小技巧:Caveman 的宏机制使得它完全可以配合#ifdef做条件编译。比如某些测试用例需要依赖特定硬件才跑得起来,你可以用预处理指令把它隔离掉,在裸机上编译时自动排除。这在嵌入式项目里特别实用,相当于用最低的成本实现了条件化测试。
如果你之前一直在用printf打点调试,或者为测试框架的复杂度头疼,那就拿 Caveman 当第一根木棍试试吧。它足够原始,也足够可靠,能帮你把"测试"这个好习惯真正扎根到 C 项目的日常开发里。