第四章:C语言单元测试框架选择:Unity、CMock、Ceedling介绍与对比
做嵌入式单元测试,选对工具框架,能省下一大半的力气。
我个人这些年折腾过不少测试框架,从最原始的纯手工assert,到后来用Unity、CMock、Ceedling这套组合拳。说实话,这三兄弟搭配起来,在嵌入式C语言测试领域,几乎成了事实标准。今天我就把它们的来龙去脉、各自分工、怎么配合,给你掰扯清楚。
4.1 Unity:轻量级测试执行器
Unity是什么?说白了,就是一个纯C语言写的测试框架。它不依赖任何操作系统,不依赖标准库,甚至不依赖malloc。你想想看,这对嵌入式开发意味着什么?意味着你可以在裸机上跑单元测试。
我记得第一次在STM32上跑Unity测试时,同事还觉得我疯了——"单片机跑单元测试?"结果跑起来后,他沉默了。Unity的代码量极小,核心文件就三个:unity.c、unity.h、unity_internals.h。全部加起来不到2000行代码。
Unity的核心能力:
- 提供TEST_ASSERT_EQUAL、TEST_ASSERT_TRUE等断言宏
- 支持TEST_GROUP、TEST_SETUP、TEST_TEARDOWN等测试夹具
- 可以生成JUnit格式的XML报告,方便CI集成
- 完全可配置,可以裁剪掉不需要的功能
来看一个最简单的例子:
#include "unity.h"
#include "my_math.h"
void setUp(void) {
// 每个测试用例执行前调用
}
void tearDown(void) {
// 每个测试用例执行后调用
}
void test_add_should_return_sum(void) {
TEST_ASSERT_EQUAL(5, add(2, 3));
TEST_ASSERT_EQUAL(0, add(-1, 1));
TEST_ASSERT_EQUAL(-5, add(-2, -3));
}
void test_add_should_handle_overflow(void) {
// 这里可以测试边界情况
TEST_ASSERT_EQUAL(INT32_MAX, add(INT32_MAX, 0));
}
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_add_should_return_sum);
RUN_TEST(test_add_should_handle_overflow);
return UNITY_END();
}
嗯,这里要注意:Unity本身不提供mock功能。它只负责执行测试、做断言、报告结果。那mock怎么办?这就轮到CMock出场了。
4.2 CMock:自动生成Mock对象
做单元测试时,最头疼的就是处理外部依赖。比如你的函数调用了ADC读取、调用了I2C发送、调用了某个硬件寄存器。这些依赖在测试环境里根本不存在,怎么办?
CMock就是干这个的。它能根据头文件自动生成mock函数。你只需要告诉它"我要mock这个头文件",它就能生成对应的模拟实现。
我曾经在一个项目里,需要mock一个复杂的传感器驱动。手动写mock代码花了两天,还一堆bug。后来换成CMock自动生成,十分钟搞定。从那以后,我再也不手写mock了。
CMock的工作流程:
- 解析头文件,提取函数声明
- 为每个函数生成mock版本
- 提供Expect和Return的API
- 在测试中验证调用次数和参数
看个实际例子:
// 假设要mock这个头文件
// sensor.h
int sensor_read_temperature(void);
int sensor_read_humidity(void);
void sensor_init(void);
// 测试代码
#include "unity.h"
#include "mock_sensor.h"
#include "my_app.h"
void test_get_avg_temperature_should_return_average(void) {
// 设置期望:第一次调用返回25,第二次返回27
sensor_read_temperature_ExpectAndReturn(25);
sensor_read_temperature_ExpectAndReturn(27);
// 执行被测函数
int avg = get_avg_temperature();
// 验证结果
TEST_ASSERT_EQUAL(26, avg);
// CMock会自动验证:sensor_read_temperature是否被调用了两次
}
CMock最强大的地方在于:它会自动检查函数调用顺序、参数值、调用次数。如果实际调用和期望不匹配,测试直接失败。这比你自己写一堆if判断要靠谱得多。
注意:CMock生成的mock代码量不小。如果你的头文件里有一百个函数,生成的mock文件可能上万行。编译时间会明显变长。我建议只mock真正需要的外部依赖,别一股脑全mock了。
4.3 Ceedling:项目级测试管理工具
有了Unity和CMock,你还需要一个东西把它们串起来——Ceedling。
Ceedling本质上是一个构建系统,它基于Ruby的Rake。它能帮你做这些事情:
- 自动发现测试文件
- 管理测试依赖
- 调用Unity执行测试
- 调用CMock生成mock
- 生成覆盖率报告
- 集成到CI流水线
说白了,Ceedling就是那个"一键执行"的工具。你不需要手动写Makefile,不需要手动配置每个测试文件。Ceedling帮你全包了。
我记得第一次用Ceedling时,最震撼的是它的项目结构:
my_project/
├── src/ # 源代码
│ ├── main.c
│ └── my_math.c
├── test/ # 测试代码
│ ├── test_my_math.c
│ └── test_main.c
├── support/ # 辅助文件
├── build/ # 构建输出
└── project.yml # Ceedling配置文件
配置文件长这样:
:project:
:build_root: build
:paths:
:source:
- src/**
:test:
- test/**
:unity:
:vendor: vendor/unity
:cmock:
:mock_path: build/mocks
:includes_h:
- unity.h
配置好之后,你只需要执行 ceedling test:all,它就会自动编译、链接、运行所有测试。如果某个测试失败,它会告诉你具体哪个断言失败了,期望值和实际值分别是多少。
4.4 三者的关系与对比
这三者的关系,我打个比方:
- Unity 就像一把螺丝刀——基础工具,用来拧螺丝(执行测试)
- CMock 就像电动螺丝刀——升级版,帮你自动处理复杂情况(生成mock)
- Ceedling 就像工具箱——把所有工具整合在一起,方便你干活(项目管理)
来看个对比表格:
| 特性 | Unity | CMock | Ceedling |
|---|---|---|---|
| 核心功能 | 测试执行与断言 | 自动生成mock | 项目管理与构建 |
| 语言 | C | C + Ruby | Ruby |
| 依赖 | 无 | Ruby环境 | Ruby + Unity + CMock |
| 适用场景 | 简单测试 | 需要mock外部依赖 | 大型项目、CI集成 |
| 学习曲线 | 低 | 中 | 中高 |
| 代码量 | ~2000行 | ~5000行 | ~10000行 |
4.5 我的选择建议
如果你刚开始做嵌入式单元测试,我建议这样选:
- 小项目(几千行代码):只用Unity就够了。手动写几个mock函数,不费事。
- 中等项目(几万行代码):Unity + CMock。Ceedling可以暂时不用,自己写个简单的Makefile。
- 大项目(十万行以上):三件套全上。Ceedling的自动化能力能帮你节省大量时间。
避坑指南:我曾经在一个项目里,把所有头文件都丢给CMock去mock。结果编译时间从30秒变成了5分钟。后来我只mock了真正需要的外部接口,内部函数直接包含真实头文件。编译时间降回40秒。记住:mock不是越多越好,够用就行。
另外,Ceedling的覆盖率报告功能也很实用。它集成了gcov,能生成HTML格式的覆盖率报告。你可以在报告里看到哪些行被覆盖了,哪些分支没跑到。这个功能在代码审查时特别好用——直接甩给同事看:"你看,这里的分支没测到,补个测试吧。"
好了,Unity、CMock、Ceedling这三兄弟就介绍到这里。下一章我会讲怎么用它们搭建一个完整的嵌入式单元测试环境,包括交叉编译、在目标板上跑测试、以及如何集成到GitLab CI里。到时候我会分享一些实战中的坑,保证让你少走弯路。