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();
}

这个结构看起来简单,但非常实用。你把它作为起点,慢慢往里面加测试用例就行。记住,测试代码和产品代码一样重要,甚至更重要——因为它是保护你产品代码的最后一道防线。

好了,关于单元测试框架,今天就聊这么多。下一节我们会讲持续集成流水线怎么搭,到时候这些测试用例就能自动跑了。