3、单元测试框架:CUnit/Unity框架介绍、测试用例编写规范、Mock对象的使用

好,咱们进入第三章。这一章聊的是单元测试框架,具体说就是 CUnit 和 Unity 这两个家伙。我在扫地机项目里,这两套框架都用过,各有各的脾气。今天我把它们掰开揉碎了讲给你听。

3.1 为什么需要单元测试框架?

你可能会问:我直接写个 main 函数,调一下被测函数,打印结果看看不就行了?嗯,早期我也是这么干的。但项目一复杂,问题就来了——

  • 测试代码散落各处,没法统一管理
  • 没有断言机制,全靠肉眼盯着打印结果
  • 没法自动化回归,改一次代码要手动跑半天

说白了,单元测试框架就是给你一套「标准化工具」。它帮你做三件事:组织测试用例、自动断言结果、生成测试报告。我个人的习惯是,项目启动第一天就把框架搭好,不然后面补测试,那叫一个痛苦。

3.2 CUnit 框架介绍

CUnit 是 C 语言里比较老牌的测试框架了。它长得有点像 JUnit,但毕竟是 C 语言,用起来更「原始」一些。

3.2.1 核心概念

  • Test Registry(测试注册表):管理所有测试套件的地方
  • Test Suite(测试套件):一组相关的测试用例集合
  • Test Case(测试用例):最小的测试单元,就是一个函数

3.2.2 一个简单的例子

假设我们要测试扫地机的「电量计算函数」。代码长这样:

#include <CUnit/CUnit.h>
#include <CUnit/Basic.h>

// 被测函数
int calculate_battery_level(int voltage_mv) {
    if (voltage_mv < 3000) return 0;   // 没电了
    if (voltage_mv > 4200) return 100; // 满电
    return (voltage_mv - 3000) / 12;    // 线性映射
}

// 测试用例
void test_battery_low(void) {
    CU_ASSERT_EQUAL(calculate_battery_level(2500), 0);
}

void test_battery_full(void) {
    CU_ASSERT_EQUAL(calculate_battery_level(4300), 100);
}

void test_battery_normal(void) {
    CU_ASSERT_EQUAL(calculate_battery_level(3600), 50);
}

int main() {
    CU_initialize_registry();
    CU_pSuite suite = CU_add_suite("Battery Test Suite", NULL, NULL);
    CU_add_test(suite, "test low battery", test_battery_low);
    CU_add_test(suite, "test full battery", test_battery_full);
    CU_add_test(suite, "test normal battery", test_battery_normal);
    CU_basic_run_tests();
    CU_cleanup_registry();
    return 0;
}

运行后你会看到类似这样的输出:

     Suite: Battery Test Suite
       Test: test low battery ... passed
       Test: test full battery ... passed
       Test: test normal battery ... passed

     --Run Summary: 3 tests, 3 passed, 0 failed--
我的小经验:CUnit 的断言宏很丰富,除了 CU_ASSERT_EQUAL,还有 CU_ASSERT_TRUE、CU_ASSERT_PTR_NULL 等。我建议你写测试时,尽量用最具体的断言,别偷懒用 CU_ASSERT(condition)。为什么?因为失败时,具体的断言能告诉你「期望值 vs 实际值」,调试起来快得多。

3.3 Unity 框架介绍

Unity 是另一个轻量级框架,专门为嵌入式环境设计的。它比 CUnit 更「小巧」,没有动态内存分配,非常适合资源受限的 MCU 环境。我在扫地机的 STM32 项目里,用的就是 Unity。

3.3.1 Unity 的特点

  • 全部用宏实现,没有运行时依赖
  • 支持跨平台,可以在 PC 上先跑,再交叉编译到目标板
  • 测试报告简洁,适合 CI 集成

3.3.2 同样的例子用 Unity 写

#include "unity.h"

int calculate_battery_level(int voltage_mv) {
    if (voltage_mv < 3000) return 0;
    if (voltage_mv > 4200) return 100;
    return (voltage_mv - 3000) / 12;
}

void setUp(void) {
    // 每个测试用例运行前执行,可以用来初始化
}

void tearDown(void) {
    // 每个测试用例运行后执行,可以用来清理
}

void test_battery_low(void) {
    TEST_ASSERT_EQUAL_INT(0, calculate_battery_level(2500));
}

void test_battery_full(void) {
    TEST_ASSERT_EQUAL_INT(100, calculate_battery_level(4300));
}

void test_battery_normal(void) {
    TEST_ASSERT_EQUAL_INT(50, calculate_battery_level(3600));
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_battery_low);
    RUN_TEST(test_battery_full);
    RUN_TEST(test_battery_normal);
    return UNITY_END();
}
CUnit vs Unity 怎么选?
对比项 CUnit Unity
内存需求 较高(需要动态分配) 极低(纯宏实现)
适用平台 PC/Linux 为主 嵌入式 MCU 首选
断言丰富度 ★★★★ ★★★★★
学习曲线 中等 平缓

我个人建议:如果你在 PC 上做单元测试,CUnit 够用;如果代码最终要跑在 MCU 上,直接用 Unity,省心。

3.4 测试用例编写规范

框架只是工具,真正重要的是怎么写好测试用例。我在项目中踩过不少坑,总结了几条铁律:

3.4.1 命名规范

  • 测试函数名:test_模块名_功能名_场景名,例如 test_motor_speed_set_normal
  • 测试文件:test_被测模块名.c,例如 test_battery.c
  • 测试套件名:与模块名保持一致

3.4.2 结构规范

每个测试用例遵循 AAA 模式:

  1. Arrange(准备):初始化数据、设置 Mock 期望
  2. Act(执行):调用被测函数
  3. Assert(断言):验证结果是否符合预期
void test_sensor_read_valid(void) {
    // Arrange
    int expected_value = 25;
    mock_adc_set_return(1, expected_value);  // 模拟 ADC 通道1返回25

    // Act
    int actual = sensor_read_temperature();

    // Assert
    TEST_ASSERT_EQUAL_INT(expected_value, actual);
}

3.4.3 边界值测试

我曾经因为没测边界值,导致扫地机在电量 0% 时还强行启动电机,直接烧了驱动芯片。从那以后,边界值测试成了我的「必选项」。

避坑指南:对于每个输入参数,至少测试:最小值、最大值、最小值-1、最大值+1、正常中间值。这 5 个用例能覆盖 90% 的边界问题。

3.5 Mock 对象的使用

嵌入式测试最头疼的是什么?依赖硬件!比如你要测试「根据传感器数据决定清扫路径」的函数,但传感器数据来自真实的 I2C 总线。这时候就需要 Mock 对象。

3.5.1 什么是 Mock?

说白了,Mock 就是「假模块」。它模拟真实模块的行为,让你能控制输入、验证输出。在扫地机项目里,我经常 Mock 这些:

  • ADC 读取函数(返回固定电压值)
  • 电机控制函数(记录调用次数和参数)
  • 定时器回调(手动触发)

3.5.2 手动 Mock 示例

假设被测函数依赖一个外部函数 int get_battery_voltage(void),我们可以这样 Mock:

// 被测模块
int get_battery_percentage(void) {
    int voltage = get_battery_voltage();  // 外部依赖
    return calculate_battery_level(voltage);
}

// 测试文件中的 Mock
static int mock_voltage = 0;

int get_battery_voltage(void) {
    return mock_voltage;
}

void test_battery_percentage_low(void) {
    mock_voltage = 2500;  // 设置 Mock 返回值
    TEST_ASSERT_EQUAL_INT(0, get_battery_percentage());
}

void test_battery_percentage_full(void) {
    mock_voltage = 4300;
    TEST_ASSERT_EQUAL_INT(100, get_battery_percentage());
}

3.5.3 使用 CMock 自动生成

手动 Mock 写多了也烦。CMock 是 Unity 的搭档,它能根据头文件自动生成 Mock 代码。你只需要告诉它「我要 Mock 哪些函数」,剩下的它帮你搞定。

// 在测试配置中声明
// 假设 battery.h 中声明了 int get_battery_voltage(void);
// CMock 会自动生成 mock_battery.h 和 mock_battery.c

#include "mock_battery.h"

void test_battery_percentage(void) {
    get_battery_voltage_ExpectAndReturn(2500);  // 设置期望
    TEST_ASSERT_EQUAL_INT(0, get_battery_percentage());
}
我的建议:对于简单场景,手动 Mock 更灵活;对于复杂接口(比如有多个参数、需要验证调用顺序),用 CMock 自动生成更省事。我在扫地机项目里,对电机控制这类「调用次数敏感」的模块,一律用 CMock。

3.6 小结

这一章我们聊了 CUnit 和 Unity 两个框架,它们就像工具箱里的两把扳手——CUnit 适合 PC 端调试,Unity 更适合嵌入式环境。测试用例的编写规范,说白了就是「命名清晰、结构分明、边界覆盖」。而 Mock 对象,是解决硬件依赖的利器。

下一章,我们会把这些知识串起来,实际搭建一个扫地机模块的单元测试环境。到时候你会看到,这些框架和技巧是怎么在真实项目中落地的。