3、C语言单元测试框架:Unity框架介绍、Ceedling构建工具、CMock模拟框架、框架选型对比

好,咱们进入第三讲。这一讲,我打算把嵌入式C语言单元测试的“三驾马车”给讲透——Unity、Ceedling、CMock。这三个东西,说白了就是一套组合拳,专门给咱们这种搞嵌入式、搞医疗软件的工程师准备的。

我记得刚入行那会儿,团队里做单元测试,有人用CUnit,有人自己手写测试桩,那叫一个乱。后来我接触到这套工具链,第一感觉就是:嗯,这才是给嵌入式人用的东西。轻量、无依赖、跑得快。

3.1 Unity框架介绍

Unity,名字听着挺唬人,其实它就是一个纯C的单元测试框架。有多纯?就两个文件:unity.c和unity.h。你把它往工程里一扔,就能用。

核心特点:

  • 极简设计:整个框架就几千行代码,没有动态内存分配,没有文件IO。说白了,就是为单片机这种资源受限的环境量身定做的。
  • 断言丰富:TEST_ASSERT_EQUAL、TEST_ASSERT_TRUE、TEST_ASSERT_STRING等,覆盖了嵌入式开发中90%的测试场景。
  • 可移植性:我曾在ARM Cortex-M、RISC-V、甚至8051上都跑过Unity,基本零改动。

来,看个最简单的例子:

#include "unity.h"

void setUp(void) {
    // 每个测试用例执行前调用,我习惯在这里初始化变量
}

void tearDown(void) {
    // 每个测试用例执行后调用,清理资源
}

void test_CalculateDose_ShouldReturnCorrectValue(void) {
    int result = calculate_dose(50, 2.5);
    TEST_ASSERT_EQUAL_INT(125, result);
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_CalculateDose_ShouldReturnCorrectValue);
    return UNITY_END();
}

你看,结构非常清晰。每个测试用例就是一个函数,用RUN_TEST去调用。我在项目中遇到过一个问题:测试用例多了以后,main函数里RUN_TEST的列表会变得很长。后来怎么解决的?用Ceedling自动管理了。

小技巧: 测试用例的命名,我建议用“test_函数名_Should_期望行为”这种格式。比如test_CalculateDose_ShouldReturnCorrectValue。这样看测试报告时,一眼就知道哪个用例挂了,挂在哪里。

3.2 Ceedling构建工具

Ceedling是什么?它本质上是一个构建系统,基于Ruby的Rake。但你别被Ruby吓到,你不需要会Ruby,只需要会写YAML配置文件就行。

Ceedling解决了什么问题?

  • 自动发现测试用例:你只要把测试文件放在test/目录下,Ceedling会自动找到所有以test_开头的文件,并编译运行。
  • 自动生成模拟(Mock):配合CMock,你只需要告诉Ceedling要模拟哪些头文件,它自动生成模拟代码。
  • 持续集成友好:一条命令ceedling test:all,跑完所有测试,输出XML或JUnit格式的报告。

我给你们看一个典型的project.yml配置:

:project:
  :build_root: build
  :test_paths:
    - test
  :source_paths:
    - src
  :include_paths:
    - include

:unity:
  :vendor_path: vendor/unity

:cmock:
  :mock_path: build/test/mocks
  :includes:
    - <stdint.h>
    - <stdbool.h>

:tools:
  :test_compiler:
    :executable: arm-none-eabi-gcc
    :arguments:
      - -I"$": COLLECTION_PATHS_TEST_TOOLCHAIN_INCLUDE
      - -I"$": COLLECTION_PATHS_TEST_SUPPORT_SOURCE_INCLUDE
      - -D$: COLLECTION_DEFINES_TEST_AND_VENDOR
      - -c "${1}"
      - -o "${2}"

嗯,这里要注意::tools部分,你可以指定交叉编译器。我在做医疗设备开发时,目标芯片是STM32,就用arm-none-eabi-gcc。这样在PC上编译的测试代码,用的就是和固件一样的编译器,能发现一些跨平台问题。

避坑指南: 我曾经因为Ceedling的Ruby版本问题折腾了一整天。建议使用Ruby 2.7以上版本,并且用gem install ceedling安装。如果你在Windows上,记得把Ruby的bin目录加到PATH里。

3.3 CMock模拟框架

CMock,说白了就是帮你自动生成“假函数”的工具。在单元测试中,我们经常需要隔离被测模块的外部依赖,比如ADC驱动、I2C通信、定时器等等。手写这些模拟函数?太累了。CMock一键生成。

CMock的工作原理:

  1. 你告诉它要模拟哪个头文件(比如adc_driver.h)。
  2. CMock解析这个头文件,为里面的每个函数生成一个模拟版本。
  3. 在测试中,你可以设置期望:这个函数应该被调用几次?参数是什么?返回值是什么?
  4. 测试结束后,CMock会自动验证期望是否被满足。

看个实际例子。假设我们有这样一个头文件:

// adc_driver.h
#ifndef ADC_DRIVER_H
#define ADC_DRIVER_H

#include <stdint.h>

void adc_init(void);
uint16_t adc_read_channel(uint8_t channel);

#endif

在Ceedling的project.yml中配置:

:cmock:
  :mock_path: build/test/mocks
  :includes_h:
    - adc_driver.h

然后,在测试代码中就可以这样用:

#include "unity.h"
#include "mock_adc_driver.h"
#include "temperature_sensor.h"  // 被测模块

void test_ReadTemperature_ShouldConvertADCValue(void) {
    // 设置期望:adc_read_channel会被调用一次,参数为0,返回1000
    adc_read_channel_ExpectAndReturn(0, 1000);

    int temp = read_temperature();

    TEST_ASSERT_EQUAL_INT(25, temp);  // 假设1000对应25度
}

你看,测试代码变得非常干净。你不需要关心adc_read_channel内部怎么实现的,你只关心它被正确调用了,并且返回了你期望的值。

个人经验: 我在做胰岛素泵的控制算法测试时,用CMock模拟了电机驱动、传感器、按键输入等十几个模块。测试代码写起来非常快,而且每次重构算法时,只要接口不变,测试用例基本不用改。这让我敢大胆地优化算法逻辑。

3.4 框架选型对比

好,咱们来聊聊选型。市面上C语言的单元测试框架不少,除了Unity,还有CUnit、Check、Google Test(有C++绑定)等。我根据自己的经验,做个对比:

特性 Unity CUnit Check Google Test (C++)
资源占用 极低(~10KB) 中等(~50KB) 中等(~40KB) 较高(~200KB+)
无动态内存
模拟框架集成 CMock(原生支持) 需手动集成 需手动集成 Google Mock
构建工具 Ceedling 无专用工具 无专用工具 CMake
适合场景 嵌入式、单片机 桌面应用 桌面应用 大型项目、C++
IEC 62304合规性 高(已验证)

为什么我强烈推荐Unity + Ceedling + CMock这套组合?说白了,三个原因:

  • 轻量:能在资源受限的MCU上跑测试,甚至可以在开发板上直接运行。
  • 自动化:Ceedling把编译、链接、运行、报告生成全包了,你只需要写测试用例。
  • 医疗合规:Unity的代码风格和文档,非常符合IEC 62304对软件单元测试的要求。我做过几次FDA审核,审核员对Unity生成的测试报告很认可。
选型建议: 如果你的项目是纯C的嵌入式系统,目标平台是ARM Cortex-M、AVR、PIC这类单片机,无脑选Unity。如果你的项目是跑在Linux上的C++应用,那Google Test更合适。但记住,咱们做医疗设备的,合规性是第一位的。Unity在IEC 62304的语境下,已经被验证过无数次了。

好,这一讲就到这里。下一讲,我会带大家手把手搭建一个完整的单元测试工程,从零开始配置Ceedling,写第一个测试用例,跑通它。到时候你们会发现,单元测试其实没那么难,关键是工具要选对。