3. 单元测试框架搭建:CUnit / Unity / CMock 的移植与配置,测试夹具的设计

单元测试框架,说白了就是给嵌入式代码做「体检」的工具。没有它,你很难知道改了一行代码会不会把别的地方搞崩。我这些年带团队,发现很多项目前期图省事不搭框架,到了集成阶段 bug 一堆,回头补测试反而更费劲。

今天咱们就聊聊三个主流框架:CUnit、Unity 和 CMock。怎么移植?怎么配?测试夹具又该怎么设计?

3.1 三大框架的定位与选型

先搞清楚它们各自是干什么的:

框架 定位 适用场景 特点
CUnit 类 xUnit 的 C 语言测试框架 PC 端测试、资源充足的嵌入式环境 功能全面,支持测试套件管理,但体积较大
Unity 轻量级 C 语言测试框架 资源受限的嵌入式目标板 极简设计,核心只有几个文件,适合裸机
CMock Mock 对象生成工具 配合 Unity 做模块隔离测试 自动生成桩函数,减少手写 Mock 的工作量

我个人习惯:PC 端开发调试用 CUnit,目标板跑测试用 Unity + CMock。为什么?CUnit 的断言和报告更丰富,调试阶段信息多;Unity 跑在板子上,内存占用小,CMock 又能自动处理依赖。

我的经验: 别在资源只有几 KB 的 MCU 上硬跑 CUnit。有一次我在 STM32F103 上试过,光框架初始化就占了 8KB RAM,剩下的空间连测试用例都塞不下。换成 Unity 后,同样的测试只用了不到 2KB。

3.2 CUnit 的移植与配置

CUnit 的移植其实不复杂,核心就三步:

  1. 下载源码:从 SourceForge 或 GitHub 获取 CUnit 源码包。
  2. 裁剪文件:嵌入式环境不需要 GUI 和 XML 输出,只保留 Console 模式。我一般只保留 CUnit 核心目录和 Console 输出模块。
  3. 配置编译:修改 configure 或直接写 Makefile,关闭不需要的功能。

这里给一个典型的 Makefile 片段:

# CUnit 移植 Makefile 示例
CC = arm-none-eabi-gcc
CFLAGS = -I./CUnit/Headers -I./CUnit/Sources -DHAVE_CONFIG_H

CUNIT_SRCS = ./CUnit/Sources/Framework/*.c \
             ./CUnit/Sources/Console/*.c

OBJS = $(CUNIT_SRCS:.c=.o)

libcunit.a: $(OBJS)
    $(AR) rcs $@ $^

%.o: %.c
    $(CC) $(CFLAGS) -c $< -o $@
注意: CUnit 默认用了 printf 和 malloc,如果你的嵌入式环境没有标准库,需要自己实现或替换。我曾经在一个 RTOS 项目里就踩过这个坑——测试跑着跑着就 HardFault,查了半天发现是 malloc 返回了 NULL。

3.3 Unity 的极简移植

Unity 的移植就轻松多了。它核心只有三个文件:unity.c、unity.h 和 unity_internals.h。

移植步骤:

  1. 把这三个文件复制到你的测试目录。
  2. 在 unity_internals.h 中配置输出函数。默认用 putchar,你可以改成串口输出。
  3. 写一个 main 函数调用 UNITY_BEGIN() 和 UNITY_END()。

看一个实际配置:

// unity_config.h
#ifndef UNITY_CONFIG_H
#define UNITY_CONFIG_H

// 自定义输出:通过串口打印测试结果
#define UNITY_OUTPUT_CHAR(a)    uart_putchar(a)
#define UNITY_OUTPUT_FLUSH()    uart_flush()
#define UNITY_OUTPUT_START()    uart_init(115200)
#define UNITY_OUTPUT_COMPLETE() uart_deinit()

#endif

嗯,这里要注意:UNITY_OUTPUT_CHAR 必须是一个可重入的函数。如果多个测试用例同时输出,你的串口驱动得支持互斥。我遇到过测试结果乱序的情况,最后发现是中断里也调了 uart_putchar。

3.4 CMock:自动生成 Mock 对象

CMock 是 Unity 的好搭档。它根据头文件自动生成桩函数,省去你手写 Mock 的麻烦。

配置 CMock 需要 Ruby 环境,因为它是个 Ruby 脚本工具。基本用法:

# 生成 Mock 文件
ruby cmock.rb -o cmock_config.yaml \
    --mock_path=./mocks \
    ./include/driver_i2c.h

配置文件 cmock_config.yaml 长这样:

# cmock_config.yaml
:cmock:
  :mock_prefix: mock_
  :plugins:
    - expect
    - return_thru_ptr
  :includes:
    - "unity.h"
  :treat_externs: :include

生成后,你会得到一个 mock_driver_i2c.c 和 mock_driver_i2c.h。在测试用例里就能这样用:

#include "mock_driver_i2c.h"

void test_sensor_read(void) {
    // 设置期望:调用 i2c_read 时返回 0x42
    i2c_read_ExpectAndReturn(0x50, 0x01, 0x42);

    // 执行被测函数
    uint8_t value = sensor_read(0x50, 0x01);

    // 验证结果
    TEST_ASSERT_EQUAL_HEX8(0x42, value);
}
避坑指南: 我曾经在一个项目里用 CMock 生成了 30 多个 Mock 文件,结果编译时间从 10 秒暴涨到 3 分钟。后来发现是 CMock 默认生成了所有函数的 Mock,包括很多内部静态函数。解决方案是在配置里用 :treat_externs: :exclude 排除不需要 Mock 的函数。

3.5 测试夹具(Test Fixture)的设计

测试夹具,就是测试用例的「舞台布景」。它包括:

  • Setup:每个测试用例执行前的准备工作
  • Teardown:每个测试用例执行后的清理工作
  • 共享资源:多个测试用例共用的数据或状态

在 Unity 中,夹具的实现很直观:

// 全局夹具变量
static uint8_t buffer[256];
static uint32_t counter;

void setUp(void) {
    // 每个测试前重置状态
    memset(buffer, 0, sizeof(buffer));
    counter = 0;
    // 初始化硬件(如果需要)
    uart_init(115200);
}

void tearDown(void) {
    // 每个测试后清理
    uart_deinit();
    // 检查是否有未释放的资源
    TEST_ASSERT_EQUAL(0, get_alloc_count());
}

设计夹具时有几个原则:

原则 说明 反例
独立性 每个测试用例的夹具互不影响 在 setUp 里依赖上一个测试的结果
可重复性 多次运行结果一致 使用随机数或未初始化的变量
最小化 只准备当前测试需要的东西 每次 setUp 都初始化所有外设
可追溯 夹具失败时能快速定位原因 在 setUp 里吞掉错误信息

我见过最糟糕的夹具设计:setUp 里调了 10 个初始化函数,其中一个失败了也不报错,结果 50 个测试用例全部失败,排查花了两天。后来我加了一条铁律:setUp 里任何一步失败,直接调用 TEST_FAIL()。

3.6 整体框架关系图

下面这张图展示了 CUnit、Unity 和 CMock 在测试流程中的位置和关系:

单元测试框架关系图 被测模块 (UUT) CUnit (PC端) Unity (目标板) CMock (Mock生成) 测试夹具 (Test Fixture) setUp / tearDown / 共享资源 / Mock 配置 测试用例集 (Test Cases) test_case_1 / test_case_2 / ... / test_case_N 说明:CUnit 适合 PC 端调试,Unity 跑在目标板,CMock 自动生成桩函数,测试夹具统一管理测试环境

3.7 综合示例:一个完整的测试夹具

最后,咱们看一个实际项目中用 Unity + CMock 搭建的完整测试夹具:

// test_flash_driver.c
#include "unity.h"
#include "mock_spi_driver.h"
#include "flash_driver.h"

// 夹具变量
static flash_config_t config;
static uint8_t test_data[256];

void setUp(void) {
    // 初始化配置
    config.spi_speed = 1000000;
    config.timeout_ms = 100;
    config.page_size = 256;

    // 初始化测试数据
    for (int i = 0; i < 256; i++) {
        test_data[i] = i & 0xFF;
    }

    // 设置 Mock 默认行为
    spi_init_ExpectAndReturn(&config, SPI_OK);
    flash_init(&config);
}

void tearDown(void) {
    // 验证所有 Mock 期望都被满足
    TEST_ASSERT_EQUAL_MESSAGE(
        CMOCK_GUTS_NONE,
        cmock_verify(),
        "有未满足的 Mock 期望!"
    );

    // 清理全局状态
    flash_deinit();
}

// 测试用例:写入一页数据
void test_flash_write_page(void) {
    // 设置 Mock 期望
    spi_write_Expect(FLASH_CMD_WRITE, 0x0000, test_data, 256);
    spi_write_Return(SPI_OK);

    // 执行
    flash_status_t status = flash_write_page(0x0000, test_data);

    // 验证
    TEST_ASSERT_EQUAL(FLASH_OK, status);
}

// 测试用例:读取一页数据
void test_flash_read_page(void) {
    uint8_t read_buf[256] = {0};

    // 设置 Mock 期望
    spi_read_Expect(FLASH_CMD_READ, 0x0100, read_buf, 256);
    spi_read_Return(SPI_OK);

    // 执行
    flash_status_t status = flash_read_page(0x0100, read_buf);

    // 验证
    TEST_ASSERT_EQUAL(FLASH_OK, status);
    TEST_ASSERT_EQUAL_HEX8_ARRAY(test_data, read_buf, 256);
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_flash_write_page);
    RUN_TEST(test_flash_read_page);
    return UNITY_END();
}
个人建议: 测试夹具的 setUp 和 tearDown 要尽量轻量。我见过有人把整个文件系统挂载卸载都放在 setUp/tearDown 里,结果跑 100 个测试用例花了 10 分钟。后来改成只在测试套件级别做一次挂载,单个测试只做状态重置,时间降到了 30 秒。

好了,框架移植和夹具设计就聊到这儿。记住:好的测试夹具是测试质量的基石。花时间把夹具搭好,后面写测试用例会顺畅很多。