3. C语言单元测试框架:Ceedling/Unity/CMock
说到嵌入式C语言的单元测试,绕不开三个名字:Unity、CMock、Ceedling。很多初学者一上来就被这三个东西搞晕了——它们到底是什么关系?我该用哪个?
我当年第一次接触这套工具链时,也花了整整两天才理清楚。说白了,Unity是测试框架本身,CMock是自动生成Mock的工具,Ceedling则是把它们打包好的构建管理工具。你可以把Ceedling理解成一个"项目经理",Unity和CMock是它手下的两个"技术骨干"。
3.1 框架选型:为什么是这套组合?
嵌入式领域其实有不少测试框架可选。我列个对比表,你一看就明白为什么我推荐这套组合了。
| 框架 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| Unity + CMock + Ceedling | 嵌入式C项目(MCU、RTOS) | 轻量、无依赖、自动Mock生成 | 不支持C++ |
| CUnit | 桌面C应用 | 功能全面 | 依赖libc、不适合交叉编译 |
| Check | Linux用户态程序 | 支持fork模式 | 嵌入式环境移植困难 |
| Google Test | C++项目 | 社区活跃、功能强大 | 太重、不适合MCU |
我个人习惯在嵌入式项目里首选Unity这套组合。为什么?因为Unity的源码就两个文件——unity.c和unity.h,加起来不到200KB。你想想看,在资源受限的MCU上,这几乎不占什么空间。而且它完全用标准C写成,没有任何外部依赖,交叉编译时特别省心。
核心结论:Unity负责"断言和报告",CMock负责"模拟外部依赖",Ceedling负责"自动化构建"。三者配合,覆盖了单元测试的完整工作流。
3.2 工程初始化:从零搭建测试项目
我记得第一次手动搭建这套环境时,光是配置路径就折腾了半小时。后来发现Ceedling提供了自动初始化命令,一步到位。
先确保你的机器上安装了Ruby(Ceedling基于Ruby开发)。然后执行:
gem install ceedling
ceedling new my_project_test
cd my_project_test
执行完后,你会看到这样的目录结构:
my_project_test/
├── src/ # 存放被测源码
├── test/ # 存放测试用例
├── lib/ # 存放第三方库(含Unity/CMock)
├── build/ # 构建输出目录
└── project.yml # Ceedling配置文件
这里有个坑,我当初踩过——src/目录默认是空的,你得把被测的C文件放进去。test/目录里放测试文件,命名规则是test_xxx.c或test_xxx.c。Ceedling会自动扫描这些文件并编译执行。
小技巧:如果你想把被测代码和测试代码分开仓库管理,可以在project.yml里通过:paths:字段指定外部源码路径。我一般把业务代码放在../src/,测试工程单独一个目录。
3.3 第一个测试用例:让LED闪烁函数通过测试
光说不练假把式。我们写一个真实的例子——测试一个LED控制函数。
假设被测代码src/led_controller.c:
#include "led_controller.h"
void led_init(void) {
/* 假设这里操作了硬件寄存器 */
GPIO_DIR |= (1 << 5);
}
void led_toggle(void) {
static uint8_t state = 0;
state = !state;
if (state) {
GPIO_OUT |= (1 << 5);
} else {
GPIO_OUT &= ~(1 << 5);
}
}
对应的头文件src/led_controller.h:
#ifndef LED_CONTROLLER_H
#define LED_CONTROLLER_H
#include <stdint.h>
#define GPIO_DIR (*(volatile uint32_t*)0x40020C00)
#define GPIO_OUT (*(volatile uint32_t*)0x40020C14)
void led_init(void);
void led_toggle(void);
#endif
现在写测试用例test/test_led_controller.c:
#include "unity.h"
#include "led_controller.h"
void setUp(void) {
/* 每个测试用例执行前调用 */
}
void tearDown(void) {
/* 每个测试用例执行后调用 */
}
void test_led_init_should_set_direction_bit(void) {
/* 先清零,模拟复位状态 */
GPIO_DIR = 0;
led_init();
TEST_ASSERT_EQUAL_HEX32(0x00000020, GPIO_DIR);
}
void test_led_toggle_should_change_output_state(void) {
GPIO_OUT = 0;
led_toggle();
TEST_ASSERT_EQUAL_HEX32(0x00000020, GPIO_OUT);
led_toggle();
TEST_ASSERT_EQUAL_HEX32(0x00000000, GPIO_OUT);
}
然后在项目根目录运行:
ceedling test:all
你会看到类似这样的输出:
Test 'test_led_controller.c'
- test_led_init_should_set_direction_bit ... PASS
- test_led_toggle_should_change_output_state ... PASS
-----------------------
2 tests, 0 failures
-----------------------
看到"PASS"两个字,心里踏实了吧?
注意:上面的例子直接操作了硬件地址。在实际项目中,我强烈建议把硬件访问层抽象出来,用CMock生成Mock对象来模拟寄存器操作。否则你的测试只能在特定硬件上跑,失去了单元测试"脱离硬件"的意义。下一章我们会详细讲Mock。
3.4 知识结构总览
为了让你对整个框架体系有个直观认识,我画了张结构图:
3.5 常用断言速查
Unity提供了丰富的断言宏。我整理了一份常用清单,写测试时直接查:
| 断言宏 | 用途 | 示例 |
|---|---|---|
TEST_ASSERT_EQUAL |
比较两个整数是否相等 | TEST_ASSERT_EQUAL(42, result); |
TEST_ASSERT_EQUAL_HEX32 |
比较32位十六进制值 | TEST_ASSERT_EQUAL_HEX32(0xFF, reg); |
TEST_ASSERT_TRUE |
判断表达式是否为真 | TEST_ASSERT_TRUE(flag); |
TEST_ASSERT_FALSE |
判断表达式是否为假 | TEST_ASSERT_FALSE(error); |
TEST_ASSERT_NULL |
判断指针是否为空 | TEST_ASSERT_NULL(ptr); |
TEST_ASSERT_EQUAL_STRING |
比较两个字符串 | TEST_ASSERT_EQUAL_STRING("OK", msg); |
TEST_ASSERT_EQUAL_FLOAT |
比较浮点数(带精度) | TEST_ASSERT_EQUAL_FLOAT(3.14, pi); |
我的习惯:比较寄存器值时,优先用TEST_ASSERT_EQUAL_HEX32而不是TEST_ASSERT_EQUAL。因为十六进制输出在调试时一眼就能看出哪个bit不对,十进制反而要换算半天。
3.6 避坑指南
做嵌入式单元测试,有几个坑我替你们先踩了:
- 不要直接测试硬件寄存器——就像上面LED的例子,虽然能跑通,但换一块MCU就废了。正确的做法是用Mock把硬件访问层替换掉。
- 注意
setUp和tearDown的执行时机——每个测试用例执行前都会调用setUp,执行后调用tearDown。如果你在setUp里分配了内存,记得在tearDown里释放,否则跑几百个用例后内存就爆了。 - 测试文件命名必须符合规则——Ceedling默认只识别
test_*.c或*_test.c格式的文件。我见过有人把测试文件命名为led_test_code.c,结果死活跑不起来,排查了半天才发现是命名问题。
我曾经在一个项目里,因为没注意setUp里的全局变量初始化,导致测试结果时好时坏。排查了整整一个下午,最后发现是上一个测试用例改了全局状态,setUp没重置干净。从那以后,我养成了一个习惯:setUp里把所有全局变量都显式初始化一遍,绝不依赖默认值。