4、单元测试框架:CUnit/Unity框架、测试用例编写、Mock对象技术
好,咱们今天聊聊单元测试框架。说实话,在安全气囊这种对可靠性要求极高的系统里,不做单元测试,我是不敢把代码合入主干的。你想想看,一个误触发或者不触发,后果是什么?所以这块内容,我建议你认真消化。
4.1 为什么选CUnit和Unity?
嵌入式环境里,能用的测试框架其实不多。CUnit是老牌框架,功能全面,但有点重。Unity是近几年兴起的,轻量、移植方便,特别适合资源受限的MCU。
我个人习惯是:在PC端做集成测试用CUnit,在目标板或仿真器上跑单元测试用Unity。为什么?因为Unity的整个核心就两个文件,你把它扔到工程里,编译一下就能跑。CUnit需要链接库,在裸机环境下折腾起来比较麻烦。
核心区别一览:
| 特性 | CUnit | Unity |
|---|---|---|
| 代码体积 | 较大(~50KB) | 极小(~5KB) |
| 依赖 | 需标准库支持 | 几乎无依赖 |
| 断言类型 | 丰富(30+) | 基础(10+) |
| 测试套件管理 | 支持 | 需手动组织 |
| 适用场景 | PC/Linux环境 | 嵌入式目标板 |
4.2 测试用例怎么写?
写测试用例,说白了就是三件事:准备输入、执行函数、验证输出。但这里有个坑——很多新手喜欢把多个测试点塞到一个用例里。我曾经见过一个同事,一个测试函数里测了五个不同的边界条件,结果失败了都不知道是哪个条件触发的。
我的建议是:一个用例只测一个逻辑点。比如我们要测试安全气囊的点火判断函数:
// 被测函数:判断是否应该点火
bool should_deploy_airbag(float acceleration, float vehicle_speed, bool seat_occupied);
// 测试用例1:正常碰撞,应该点火
void test_should_deploy_when_crash(void) {
TEST_ASSERT_TRUE(should_deploy_airbag(50.0f, 60.0f, true));
}
// 测试用例2:未系安全带,不应点火
void test_should_not_deploy_when_seat_empty(void) {
TEST_ASSERT_FALSE(should_deploy_airbag(50.0f, 60.0f, false));
}
// 测试用例3:低速碰撞,不应点火
void test_should_not_deploy_when_low_speed(void) {
TEST_ASSERT_FALSE(should_deploy_airbag(5.0f, 10.0f, true));
}
你看,每个用例的名字就说明了它在测什么。将来出问题了,一眼就能定位到是哪个条件没处理好。
小技巧:测试用例的命名建议用“test_被测功能_预期行为”的格式。比如 test_should_deploy_when_crash,比 test_case_001 好一百倍。
4.3 Mock对象技术——解决依赖难题
做嵌入式单元测试,最头疼的是什么?是硬件依赖。你的函数里调用了ADC读取、GPIO操作、CAN发送……这些在PC上根本跑不了。怎么办?用Mock。
Mock说白了就是造一个假的函数,替换掉真实的硬件操作。比如你的气囊控制模块里调用了 read_accelerometer(),在测试时,我们不调用真实的传感器驱动,而是用一个Mock函数返回我们想要的值。
我常用的Mock框架是 CMock,它和Unity是同一家的产品,配合起来非常顺。但如果你不想引入额外框架,手写Mock也完全可以。
// 真实函数(在硬件上运行)
float read_accelerometer(void) {
// 读取SPI寄存器,返回加速度值
return spi_read_register(ACCEL_REG);
}
// Mock函数(在测试中替换)
static float mock_accel_value = 0.0f;
float read_accelerometer(void) {
return mock_accel_value;
}
// 测试用例:设置Mock返回50m/s²,验证点火逻辑
void test_deploy_with_mock_accel(void) {
mock_accel_value = 50.0f; // 模拟碰撞
TEST_ASSERT_TRUE(should_deploy_airbag());
mock_accel_value = 2.0f; // 模拟正常行驶
TEST_ASSERT_FALSE(should_deploy_airbag());
}
注意:使用Mock时,一定要确保Mock函数的签名和原函数完全一致。我曾经因为参数类型不匹配,排查了整整一个下午。嗯,那滋味不好受。
4.4 实际项目中的避坑指南
讲几个我踩过的坑,你遇到了可以少走弯路:
- 不要Mock所有东西。 有些简单的数学函数、状态机逻辑,直接测真实代码就好。过度Mock会让测试失去意义。
- 注意全局变量。 安全气囊软件里有很多全局状态(比如系统自检状态、故障码)。每个测试用例执行前,记得重置这些全局变量。我习惯在setUp()函数里统一做这件事。
- 测试覆盖率不是越高越好。 我曾经追求100%覆盖率,结果发现很多代码路径是死代码或者异常处理。安全气囊软件里,关键路径的覆盖率比总覆盖率重要得多。
- 定时器相关的测试要小心。 嵌入式里很多逻辑依赖时间。我的做法是把定时器相关的函数也Mock掉,让测试用例直接控制时间流逝。
4.5 一个完整的测试套件示例
最后,给你看一个完整的Unity测试套件结构。这是我在一个安全气囊项目中实际用过的模板:
#include "unity.h"
#include "airbag_control.h"
// Mock变量
static bool mock_ignition_on = false;
static float mock_accel_value = 0.0f;
// 每个用例前执行
void setUp(void) {
mock_ignition_on = true;
mock_accel_value = 0.0f;
airbag_system_reset();
}
// 每个用例后执行
void tearDown(void) {
// 清理资源,关闭外设等
}
// 测试用例
void test_deploy_when_crash_detected(void) {
mock_accel_value = 60.0f;
TEST_ASSERT_TRUE(airbag_should_deploy());
}
void test_no_deploy_when_ignition_off(void) {
mock_ignition_on = false;
mock_accel_value = 60.0f;
TEST_ASSERT_FALSE(airbag_should_deploy());
}
// 主函数
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_deploy_when_crash_detected);
RUN_TEST(test_no_deploy_when_ignition_off);
return UNITY_END();
}
这个结构看起来简单,但非常实用。你把它作为起点,慢慢往里面加测试用例就行。记住,测试代码和产品代码一样重要,甚至更重要——因为它是保护你产品代码的最后一道防线。
好了,关于单元测试框架,今天就聊这么多。下一节我们会讲持续集成流水线怎么搭,到时候这些测试用例就能自动跑了。