3. 单元测试框架搭建:CUnit / Unity / CMock 的移植与配置,测试夹具的设计
单元测试框架,说白了就是给嵌入式代码做「体检」的工具。没有它,你很难知道改了一行代码会不会把别的地方搞崩。我这些年带团队,发现很多项目前期图省事不搭框架,到了集成阶段 bug 一堆,回头补测试反而更费劲。
今天咱们就聊聊三个主流框架:CUnit、Unity 和 CMock。怎么移植?怎么配?测试夹具又该怎么设计?
3.1 三大框架的定位与选型
先搞清楚它们各自是干什么的:
| 框架 | 定位 | 适用场景 | 特点 |
|---|---|---|---|
| CUnit | 类 xUnit 的 C 语言测试框架 | PC 端测试、资源充足的嵌入式环境 | 功能全面,支持测试套件管理,但体积较大 |
| Unity | 轻量级 C 语言测试框架 | 资源受限的嵌入式目标板 | 极简设计,核心只有几个文件,适合裸机 |
| CMock | Mock 对象生成工具 | 配合 Unity 做模块隔离测试 | 自动生成桩函数,减少手写 Mock 的工作量 |
我个人习惯:PC 端开发调试用 CUnit,目标板跑测试用 Unity + CMock。为什么?CUnit 的断言和报告更丰富,调试阶段信息多;Unity 跑在板子上,内存占用小,CMock 又能自动处理依赖。
3.2 CUnit 的移植与配置
CUnit 的移植其实不复杂,核心就三步:
- 下载源码:从 SourceForge 或 GitHub 获取 CUnit 源码包。
- 裁剪文件:嵌入式环境不需要 GUI 和 XML 输出,只保留 Console 模式。我一般只保留
CUnit核心目录和Console输出模块。 - 配置编译:修改
configure或直接写 Makefile,关闭不需要的功能。
这里给一个典型的 Makefile 片段:
# CUnit 移植 Makefile 示例
CC = arm-none-eabi-gcc
CFLAGS = -I./CUnit/Headers -I./CUnit/Sources -DHAVE_CONFIG_H
CUNIT_SRCS = ./CUnit/Sources/Framework/*.c \
./CUnit/Sources/Console/*.c
OBJS = $(CUNIT_SRCS:.c=.o)
libcunit.a: $(OBJS)
$(AR) rcs $@ $^
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
printf 和 malloc,如果你的嵌入式环境没有标准库,需要自己实现或替换。我曾经在一个 RTOS 项目里就踩过这个坑——测试跑着跑着就 HardFault,查了半天发现是 malloc 返回了 NULL。
3.3 Unity 的极简移植
Unity 的移植就轻松多了。它核心只有三个文件:unity.c、unity.h 和 unity_internals.h。
移植步骤:
- 把这三个文件复制到你的测试目录。
- 在
unity_internals.h中配置输出函数。默认用putchar,你可以改成串口输出。 - 写一个
main函数调用UNITY_BEGIN()和UNITY_END()。
看一个实际配置:
// unity_config.h
#ifndef UNITY_CONFIG_H
#define UNITY_CONFIG_H
// 自定义输出:通过串口打印测试结果
#define UNITY_OUTPUT_CHAR(a) uart_putchar(a)
#define UNITY_OUTPUT_FLUSH() uart_flush()
#define UNITY_OUTPUT_START() uart_init(115200)
#define UNITY_OUTPUT_COMPLETE() uart_deinit()
#endif
嗯,这里要注意:UNITY_OUTPUT_CHAR 必须是一个可重入的函数。如果多个测试用例同时输出,你的串口驱动得支持互斥。我遇到过测试结果乱序的情况,最后发现是中断里也调了 uart_putchar。
3.4 CMock:自动生成 Mock 对象
CMock 是 Unity 的好搭档。它根据头文件自动生成桩函数,省去你手写 Mock 的麻烦。
配置 CMock 需要 Ruby 环境,因为它是个 Ruby 脚本工具。基本用法:
# 生成 Mock 文件
ruby cmock.rb -o cmock_config.yaml \
--mock_path=./mocks \
./include/driver_i2c.h
配置文件 cmock_config.yaml 长这样:
# cmock_config.yaml
:cmock:
:mock_prefix: mock_
:plugins:
- expect
- return_thru_ptr
:includes:
- "unity.h"
:treat_externs: :include
生成后,你会得到一个 mock_driver_i2c.c 和 mock_driver_i2c.h。在测试用例里就能这样用:
#include "mock_driver_i2c.h"
void test_sensor_read(void) {
// 设置期望:调用 i2c_read 时返回 0x42
i2c_read_ExpectAndReturn(0x50, 0x01, 0x42);
// 执行被测函数
uint8_t value = sensor_read(0x50, 0x01);
// 验证结果
TEST_ASSERT_EQUAL_HEX8(0x42, value);
}
:treat_externs: :exclude 排除不需要 Mock 的函数。
3.5 测试夹具(Test Fixture)的设计
测试夹具,就是测试用例的「舞台布景」。它包括:
- Setup:每个测试用例执行前的准备工作
- Teardown:每个测试用例执行后的清理工作
- 共享资源:多个测试用例共用的数据或状态
在 Unity 中,夹具的实现很直观:
// 全局夹具变量
static uint8_t buffer[256];
static uint32_t counter;
void setUp(void) {
// 每个测试前重置状态
memset(buffer, 0, sizeof(buffer));
counter = 0;
// 初始化硬件(如果需要)
uart_init(115200);
}
void tearDown(void) {
// 每个测试后清理
uart_deinit();
// 检查是否有未释放的资源
TEST_ASSERT_EQUAL(0, get_alloc_count());
}
设计夹具时有几个原则:
| 原则 | 说明 | 反例 |
|---|---|---|
| 独立性 | 每个测试用例的夹具互不影响 | 在 setUp 里依赖上一个测试的结果 |
| 可重复性 | 多次运行结果一致 | 使用随机数或未初始化的变量 |
| 最小化 | 只准备当前测试需要的东西 | 每次 setUp 都初始化所有外设 |
| 可追溯 | 夹具失败时能快速定位原因 | 在 setUp 里吞掉错误信息 |
我见过最糟糕的夹具设计:setUp 里调了 10 个初始化函数,其中一个失败了也不报错,结果 50 个测试用例全部失败,排查花了两天。后来我加了一条铁律:setUp 里任何一步失败,直接调用 TEST_FAIL()。
3.6 整体框架关系图
下面这张图展示了 CUnit、Unity 和 CMock 在测试流程中的位置和关系:
3.7 综合示例:一个完整的测试夹具
最后,咱们看一个实际项目中用 Unity + CMock 搭建的完整测试夹具:
// test_flash_driver.c
#include "unity.h"
#include "mock_spi_driver.h"
#include "flash_driver.h"
// 夹具变量
static flash_config_t config;
static uint8_t test_data[256];
void setUp(void) {
// 初始化配置
config.spi_speed = 1000000;
config.timeout_ms = 100;
config.page_size = 256;
// 初始化测试数据
for (int i = 0; i < 256; i++) {
test_data[i] = i & 0xFF;
}
// 设置 Mock 默认行为
spi_init_ExpectAndReturn(&config, SPI_OK);
flash_init(&config);
}
void tearDown(void) {
// 验证所有 Mock 期望都被满足
TEST_ASSERT_EQUAL_MESSAGE(
CMOCK_GUTS_NONE,
cmock_verify(),
"有未满足的 Mock 期望!"
);
// 清理全局状态
flash_deinit();
}
// 测试用例:写入一页数据
void test_flash_write_page(void) {
// 设置 Mock 期望
spi_write_Expect(FLASH_CMD_WRITE, 0x0000, test_data, 256);
spi_write_Return(SPI_OK);
// 执行
flash_status_t status = flash_write_page(0x0000, test_data);
// 验证
TEST_ASSERT_EQUAL(FLASH_OK, status);
}
// 测试用例:读取一页数据
void test_flash_read_page(void) {
uint8_t read_buf[256] = {0};
// 设置 Mock 期望
spi_read_Expect(FLASH_CMD_READ, 0x0100, read_buf, 256);
spi_read_Return(SPI_OK);
// 执行
flash_status_t status = flash_read_page(0x0100, read_buf);
// 验证
TEST_ASSERT_EQUAL(FLASH_OK, status);
TEST_ASSERT_EQUAL_HEX8_ARRAY(test_data, read_buf, 256);
}
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_flash_write_page);
RUN_TEST(test_flash_read_page);
return UNITY_END();
}
好了,框架移植和夹具设计就聊到这儿。记住:好的测试夹具是测试质量的基石。花时间把夹具搭好,后面写测试用例会顺畅很多。