第四章:C语言单元测试框架选择:Unity、CMock、Ceedling介绍与对比

做嵌入式单元测试,选对工具框架,能省下一大半的力气。

我个人这些年折腾过不少测试框架,从最原始的纯手工assert,到后来用Unity、CMock、Ceedling这套组合拳。说实话,这三兄弟搭配起来,在嵌入式C语言测试领域,几乎成了事实标准。今天我就把它们的来龙去脉、各自分工、怎么配合,给你掰扯清楚。

4.1 Unity:轻量级测试执行器

Unity是什么?说白了,就是一个纯C语言写的测试框架。它不依赖任何操作系统,不依赖标准库,甚至不依赖malloc。你想想看,这对嵌入式开发意味着什么?意味着你可以在裸机上跑单元测试。

我记得第一次在STM32上跑Unity测试时,同事还觉得我疯了——"单片机跑单元测试?"结果跑起来后,他沉默了。Unity的代码量极小,核心文件就三个:unity.c、unity.h、unity_internals.h。全部加起来不到2000行代码。

Unity的核心能力:

  • 提供TEST_ASSERT_EQUAL、TEST_ASSERT_TRUE等断言宏
  • 支持TEST_GROUP、TEST_SETUP、TEST_TEARDOWN等测试夹具
  • 可以生成JUnit格式的XML报告,方便CI集成
  • 完全可配置,可以裁剪掉不需要的功能

来看一个最简单的例子:

#include "unity.h"
#include "my_math.h"

void setUp(void) {
    // 每个测试用例执行前调用
}

void tearDown(void) {
    // 每个测试用例执行后调用
}

void test_add_should_return_sum(void) {
    TEST_ASSERT_EQUAL(5, add(2, 3));
    TEST_ASSERT_EQUAL(0, add(-1, 1));
    TEST_ASSERT_EQUAL(-5, add(-2, -3));
}

void test_add_should_handle_overflow(void) {
    // 这里可以测试边界情况
    TEST_ASSERT_EQUAL(INT32_MAX, add(INT32_MAX, 0));
}

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

嗯,这里要注意:Unity本身不提供mock功能。它只负责执行测试、做断言、报告结果。那mock怎么办?这就轮到CMock出场了。

4.2 CMock:自动生成Mock对象

做单元测试时,最头疼的就是处理外部依赖。比如你的函数调用了ADC读取、调用了I2C发送、调用了某个硬件寄存器。这些依赖在测试环境里根本不存在,怎么办?

CMock就是干这个的。它能根据头文件自动生成mock函数。你只需要告诉它"我要mock这个头文件",它就能生成对应的模拟实现。

我曾经在一个项目里,需要mock一个复杂的传感器驱动。手动写mock代码花了两天,还一堆bug。后来换成CMock自动生成,十分钟搞定。从那以后,我再也不手写mock了。

CMock的工作流程:

  1. 解析头文件,提取函数声明
  2. 为每个函数生成mock版本
  3. 提供Expect和Return的API
  4. 在测试中验证调用次数和参数

看个实际例子:

// 假设要mock这个头文件
// sensor.h
int sensor_read_temperature(void);
int sensor_read_humidity(void);
void sensor_init(void);

// 测试代码
#include "unity.h"
#include "mock_sensor.h"
#include "my_app.h"

void test_get_avg_temperature_should_return_average(void) {
    // 设置期望:第一次调用返回25,第二次返回27
    sensor_read_temperature_ExpectAndReturn(25);
    sensor_read_temperature_ExpectAndReturn(27);
    
    // 执行被测函数
    int avg = get_avg_temperature();
    
    // 验证结果
    TEST_ASSERT_EQUAL(26, avg);
    
    // CMock会自动验证:sensor_read_temperature是否被调用了两次
}

CMock最强大的地方在于:它会自动检查函数调用顺序、参数值、调用次数。如果实际调用和期望不匹配,测试直接失败。这比你自己写一堆if判断要靠谱得多。

注意:CMock生成的mock代码量不小。如果你的头文件里有一百个函数,生成的mock文件可能上万行。编译时间会明显变长。我建议只mock真正需要的外部依赖,别一股脑全mock了。

4.3 Ceedling:项目级测试管理工具

有了Unity和CMock,你还需要一个东西把它们串起来——Ceedling。

Ceedling本质上是一个构建系统,它基于Ruby的Rake。它能帮你做这些事情:

  • 自动发现测试文件
  • 管理测试依赖
  • 调用Unity执行测试
  • 调用CMock生成mock
  • 生成覆盖率报告
  • 集成到CI流水线

说白了,Ceedling就是那个"一键执行"的工具。你不需要手动写Makefile,不需要手动配置每个测试文件。Ceedling帮你全包了。

我记得第一次用Ceedling时,最震撼的是它的项目结构:

my_project/
├── src/              # 源代码
│   ├── main.c
│   └── my_math.c
├── test/             # 测试代码
│   ├── test_my_math.c
│   └── test_main.c
├── support/          # 辅助文件
├── build/            # 构建输出
└── project.yml       # Ceedling配置文件

配置文件长这样:

:project:
  :build_root: build
  
:paths:
  :source:
    - src/**
  :test:
    - test/**
    
:unity:
  :vendor: vendor/unity
  
:cmock:
  :mock_path: build/mocks
  :includes_h:
    - unity.h

配置好之后,你只需要执行 ceedling test:all,它就会自动编译、链接、运行所有测试。如果某个测试失败,它会告诉你具体哪个断言失败了,期望值和实际值分别是多少。

4.4 三者的关系与对比

这三者的关系,我打个比方:

  • Unity 就像一把螺丝刀——基础工具,用来拧螺丝(执行测试)
  • CMock 就像电动螺丝刀——升级版,帮你自动处理复杂情况(生成mock)
  • Ceedling 就像工具箱——把所有工具整合在一起,方便你干活(项目管理)

来看个对比表格:

特性 Unity CMock Ceedling
核心功能 测试执行与断言 自动生成mock 项目管理与构建
语言 C C + Ruby Ruby
依赖 Ruby环境 Ruby + Unity + CMock
适用场景 简单测试 需要mock外部依赖 大型项目、CI集成
学习曲线 中高
代码量 ~2000行 ~5000行 ~10000行

4.5 我的选择建议

如果你刚开始做嵌入式单元测试,我建议这样选:

  • 小项目(几千行代码):只用Unity就够了。手动写几个mock函数,不费事。
  • 中等项目(几万行代码):Unity + CMock。Ceedling可以暂时不用,自己写个简单的Makefile。
  • 大项目(十万行以上):三件套全上。Ceedling的自动化能力能帮你节省大量时间。

避坑指南:我曾经在一个项目里,把所有头文件都丢给CMock去mock。结果编译时间从30秒变成了5分钟。后来我只mock了真正需要的外部接口,内部函数直接包含真实头文件。编译时间降回40秒。记住:mock不是越多越好,够用就行。

另外,Ceedling的覆盖率报告功能也很实用。它集成了gcov,能生成HTML格式的覆盖率报告。你可以在报告里看到哪些行被覆盖了,哪些分支没跑到。这个功能在代码审查时特别好用——直接甩给同事看:"你看,这里的分支没测到,补个测试吧。"

好了,Unity、CMock、Ceedling这三兄弟就介绍到这里。下一章我会讲怎么用它们搭建一个完整的嵌入式单元测试环境,包括交叉编译、在目标板上跑测试、以及如何集成到GitLab CI里。到时候我会分享一些实战中的坑,保证让你少走弯路。