第三章 单元测试框架入门: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_SETUPTEST_TEAR_DOWN。我在项目中遇到过,有人把所有初始化都写在TEST_SETUP里,结果每个测试用例都跑一遍,耗时翻倍。我的建议是:只放每个用例都需要的公共初始化,比如清零一个全局变量。那些耗时的外设初始化,放到测试用例内部去做。

3.2 CMock框架:模拟依赖的利器

CMock是Unity的搭档,专门用来生成模拟函数(mock)。为什么要模拟?举个例子,你写了一个温度传感器驱动,它依赖I2C读写函数。测试驱动时,你总不能真的接个传感器吧?这时候就需要CMock来模拟I2C的行为。

CMock的使用流程是这样的:

  1. 准备好你要模拟的头文件,比如i2c_driver.h
  2. 运行CMock的Ruby脚本,生成模拟代码
  3. 在测试用例中设置期望的调用和返回值

我记得第一次用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,一看就知道测的是什么场景。

我曾经在一个项目中,接手别人的测试代码,用例名全是test1test2……你想想看,那维护起来有多痛苦。后来我花了一整天,把所有用例名改成了有意义的命名。从那以后,团队就定下了命名规范。

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的基本用法就这些。下一章咱们聊聊如何把这些框架集成到你的项目中,以及如何自动化运行测试。记住,测试不是负担,它是你代码的「安全带」。