第三章 单元测试框架入门:Unity/CMock框架介绍、测试用例编写规范、断言的使用
好,咱们今天聊聊单元测试框架。说实话,我早年做单片机开发时,压根儿没想过什么单元测试。那时候觉得,代码能跑、功能正常就完事了。直到有一次,一个看似简单的按键扫描函数,在项目后期反复出bug,改一次崩一次……嗯,从那以后,我才真正重视起单元测试来。
今天要讲的Unity和CMock,是我个人在嵌入式项目里用得最顺手的两个工具。它们轻量、无依赖,特别适合资源受限的单片机环境。你想想看,一个只有几千行代码的STM32项目,用它们做单元测试,几乎不增加任何编译负担。
3.1 Unity框架:轻量级测试引擎
Unity是什么?说白了,就是一个纯C语言的单元测试框架。它不像CppUTest那样需要C++编译器,也不像Google Test那样庞大。Unity就一个.c文件加一个.h头文件,往项目里一丢就能用。
我习惯把Unity的源码直接放在项目的test/unity/目录下。它的核心API其实就几个:
// 测试组
TEST_GROUP(组名);
TEST_SETUP(组名) { /* 每个测试用例执行前调用 */ }
TEST_TEAR_DOWN(组名) { /* 每个测试用例执行后调用 */ }
// 测试用例
TEST(组名, 测试用例名) {
// 测试逻辑
}
// 运行测试
TEST_GROUP_RUNNER(组名) {
RUN_TEST_CASE(组名, 测试用例名);
}
这里有个小细节——TEST_SETUP和TEST_TEAR_DOWN。我在项目中遇到过,有人把所有初始化都写在TEST_SETUP里,结果每个测试用例都跑一遍,耗时翻倍。我的建议是:只放每个用例都需要的公共初始化,比如清零一个全局变量。那些耗时的外设初始化,放到测试用例内部去做。
3.2 CMock框架:模拟依赖的利器
CMock是Unity的搭档,专门用来生成模拟函数(mock)。为什么要模拟?举个例子,你写了一个温度传感器驱动,它依赖I2C读写函数。测试驱动时,你总不能真的接个传感器吧?这时候就需要CMock来模拟I2C的行为。
CMock的使用流程是这样的:
- 准备好你要模拟的头文件,比如
i2c_driver.h - 运行CMock的Ruby脚本,生成模拟代码
- 在测试用例中设置期望的调用和返回值
我记得第一次用CMock时,被它的配置折腾了半天。后来发现,关键就两个命令:
# 生成模拟代码
ruby cmock.rb -o i2c_driver.h
# 在测试用例中设置期望
I2C_Read_ExpectAndReturn(0x50, 0x01, 0xAB); // 期望调用I2C_Read(地址0x50, 寄存器0x01),返回0xAB
嗯,这里要注意:CMock生成的模拟函数,默认会检查参数是否匹配。如果实际调用时参数不对,测试会直接失败。这个特性帮我抓出过不少参数传递错误——有一次我传错了I2C地址,要不是CMock报错,可能到联调时才发现。
3.3 测试用例编写规范
写测试用例,说白了就是给代码做体检。我总结了三条铁律:
铁律一:一个测试用例只测一件事
别想着一个用例覆盖所有分支。比如测试一个计算校验和的函数,就拆成:
- test_checksum_empty_input
- test_checksum_single_byte
- test_checksum_multiple_bytes
- test_checksum_overflow
铁律二:测试用例要可重复
每次运行结果必须一致。我见过有人测试里用了rand(),结果今天跑过,明天跑不过,查了半天是随机数种子的问题。别干这种事。
铁律三:命名要自解释
测试用例名就是文档。比如test_adc_read_returns_zero_when_channel_invalid,一看就知道测的是什么场景。
我曾经在一个项目中,接手别人的测试代码,用例名全是test1、test2……你想想看,那维护起来有多痛苦。后来我花了一整天,把所有用例名改成了有意义的命名。从那以后,团队就定下了命名规范。
3.4 断言的使用:让测试说话
断言是测试用例的灵魂。Unity提供了丰富的断言宏,我挑几个最常用的:
| 断言宏 | 用途 | 示例 |
|---|---|---|
| TEST_ASSERT_EQUAL | 判断两个整数相等 | TEST_ASSERT_EQUAL(100, result); |
| TEST_ASSERT_EQUAL_FLOAT | 判断浮点数相等(带容差) | TEST_ASSERT_EQUAL_FLOAT(3.14, pi); |
| TEST_ASSERT_TRUE/FALSE | 判断布尔值 | TEST_ASSERT_TRUE(is_ready); |
| TEST_ASSERT_NULL/NOT_NULL | 判断指针 | TEST_ASSERT_NOT_NULL(buffer); |
| TEST_ASSERT_EQUAL_STRING | 判断字符串相等 | TEST_ASSERT_EQUAL_STRING("OK", msg); |
小技巧:断言失败时,Unity会打印出期望值和实际值。但如果你用TEST_ASSERT_EQUAL_INT这种带类型的宏,打印信息会更清晰。我习惯在断言后面加个自定义消息:
TEST_ASSERT_EQUAL_INT_MESSAGE(0x55, reg_value, "寄存器0x01的值应为0x55");
为什么断言这么重要?因为它是测试的「裁判」。没有断言的测试,就像没有终点的赛跑——跑完了也不知道对不对。我见过有人写测试用例,里面全是printf打印,然后人工看输出。这哪叫测试?这叫「人工验尸」。
3.5 实战:一个完整的测试示例
咱们来写一个实际的测试。假设有一个计算环形缓冲区剩余空间的函数:
// ring_buffer.c
uint32_t ring_buffer_available(RingBuffer* rb) {
if (rb->write_index >= rb->read_index) {
return rb->size - (rb->write_index - rb->read_index) - 1;
} else {
return rb->read_index - rb->write_index - 1;
}
}
对应的测试用例:
// test_ring_buffer.c
#include "unity.h"
#include "ring_buffer.h"
static RingBuffer rb;
void setUp(void) {
rb.size = 16;
rb.read_index = 0;
rb.write_index = 0;
}
void tearDown(void) {
// 不需要清理
}
TEST(ring_buffer, available_when_empty) {
uint32_t avail = ring_buffer_available(&rb);
TEST_ASSERT_EQUAL_UINT32(15, avail); // 16字节缓冲区,空时可用15字节
}
TEST(ring_buffer, available_when_full) {
rb.write_index = 15;
rb.read_index = 0;
uint32_t avail = ring_buffer_available(&rb);
TEST_ASSERT_EQUAL_UINT32(0, avail); // 满了,可用0字节
}
TEST(ring_buffer, available_after_wrap) {
rb.write_index = 3;
rb.read_index = 12;
uint32_t avail = ring_buffer_available(&rb);
TEST_ASSERT_EQUAL_UINT32(8, avail); // 绕回后,可用8字节
}
注意:这个测试里,我故意没测边界情况——比如write_index等于read_index时。为什么?因为实际项目中,这个状态表示缓冲区为空,但函数里没有处理。嗯,这就是测试的价值:它能暴露代码的盲区。
跑一下测试:
Unity test run 1 of 3: ring_buffer.available_when_empty ... PASS
Unity test run 2 of 3: ring_buffer.available_when_full ... PASS
Unity test run 3 of 3: ring_buffer.available_after_wrap ... PASS
-----------------------
3 tests 0 failures 0 ignored
看到这个绿色的PASS,心里踏实多了吧?
3.6 避坑指南
最后,分享几个我踩过的坑:
- 别在测试里用延时函数——我曾经在测试里放了
HAL_Delay(100),结果整个测试套件跑下来要几分钟。后来改成用模拟时钟。 - 注意全局变量的污染——多个测试用例共享全局变量时,记得在
setUp里重置。我吃过一次亏:一个用例改了全局状态,下一个用例直接崩了。 - 别忽略编译警告——Unity的测试代码也是代码,编译警告往往意味着潜在问题。我习惯把警告等级开到最高。
好了,Unity和CMock的基本用法就这些。下一章咱们聊聊如何把这些框架集成到你的项目中,以及如何自动化运行测试。记住,测试不是负担,它是你代码的「安全带」。