3. 单元测试基础回顾
做嵌入式单元测试,选对工具链,项目就成功了一半。我个人这些年折腾过不少框架,从早期的自制测试桩,到后来用 CppUTest,最后在 C 项目里彻底倒向了 Ceedling + Unity + CMock 这套组合。
为什么?说白了,这三兄弟是专门为嵌入式 C 代码设计的。Unity 负责跑测试用例,CMock 帮你自动生成 mock 函数,Ceedling 则是那个把一切串起来的大管家。你想想看,一个 make 命令就能完成编译、链接、运行、出报告,多省心。
核心结论:对于资源受限的嵌入式 C 项目,Ceedling + Unity + CMock 是目前最成熟的单元测试方案。它不依赖操作系统,不依赖标准库,甚至可以在没有硬件的情况下跑通大部分逻辑测试。
3.1 三兄弟各司其职
先搞清楚每个工具到底干什么活。我见过不少新手把 Unity 和 Ceedling 混为一谈,其实它们分工很明确。
| 工具 | 角色 | 核心能力 | 我常用的场景 |
|---|---|---|---|
| Unity | 测试执行引擎 | 提供 TEST_ASSERT 宏族,运行测试用例,输出结果 | 纯函数测试、状态机测试、无外部依赖的模块 |
| CMock | Mock 生成器 | 解析头文件,自动生成 mock 函数、桩代码、期望检查 | 驱动层测试、硬件抽象层测试、依赖外部接口的模块 |
| Ceedling | 构建与测试管理 | 项目初始化、编译调度、测试运行、覆盖率收集、报告生成 | 所有项目的日常测试流程 |
我的习惯:Unity 负责「测得准」,CMock 负责「替得掉」,Ceedling 负责「跑得快」。三者缺一不可,但千万别让 CMock 去干 Unity 的活——mock 不是用来做断言断言的,它只负责模拟外部行为。
3.2 集成配置实战
光说不练假把式。咱们直接上手配一个真实项目。我假设你已经在 Linux 或 macOS 上装了 Ruby(Ceedling 依赖 Ruby),Windows 用户用 WSL 也一样。
3.2.1 安装与初始化
# 安装 Ceedling(它会自动拉 Unity 和 CMock)
gem install ceedling
# 创建新项目
ceedling new my_event_driven_project
cd my_event_driven_project
跑完这两条命令,你会看到这样的目录结构:
my_event_driven_project/
├── src/ # 你的生产代码放这里
├── test/ # 测试代码放这里
├── lib/ # 第三方库(Unity/CMock 自动放在这)
├── vendor/ # 自定义依赖
└── project.yml # Ceedling 配置文件(核心!)
嗯,这里要注意:project.yml 是整个测试工程的灵魂。我早期犯过一个错,直接在默认配置上开干,结果编译报错找半天——原来是头文件路径没配对。
3.2.2 配置 project.yml
下面是我在一个事件驱动架构项目里用过的配置,加了详细注释:
:project:
:build_root: build/ # 编译输出目录
:use_exceptions: FALSE # 嵌入式环境一般不启用异常
:use_mocks: TRUE # 启用 CMock
:paths:
:source: # 生产代码路径
- src/**
- src/event_driven/**
:test: # 测试代码路径
- test/**
:include: # 额外头文件路径
- include/**
- config/
:cmock:
:mock_prefix: mock_ # mock 文件前缀
:includes: # mock 生成时自动包含的头
- "unity.h"
- "cmock.h"
:treat_externs: :include # extern 变量处理方式
:when_no_prototypes: :warn # 找不到函数原型时只警告
:unity:
:enforce_strict_ordering: TRUE # 强制 TEST_ASSERT 顺序
:tools:
:test_compiler:
:executable: arm-none-eabi-gcc # 交叉编译器
:arguments:
- -mcpu=cortex-m4
- -mthumb
- -Wall -Werror
- -I"$": COLLECTION_PATHS_TEST_TOOLCHAIN_INCLUDE
- -D$: COLLECTION_DEFINES_TEST_AND_VENDOR
- "$": COLLECTION_PATHS_TEST
- "$": COLLECTION_PATHS_SOURCE
- "$": COLLECTION_PATHS_INCLUDE
我曾经踩过的坑:在配置交叉编译工具链时,忘了加 -specs=nano.specs 或 -nostartfiles,结果链接器报一堆 undefined reference。后来我养成了一个习惯——先在本地 gcc 上跑通测试,再切到交叉编译器。这样能快速区分是代码问题还是工具链问题。
3.3 编写第一个测试用例
配置搞定了,咱们写个测试试试。假设 src/event_queue.c 里有个函数 event_queue_push,它依赖一个硬件定时器接口 timer_start。
测试文件 test/test_event_queue.c:
#include "unity.h"
#include "mock_timer.h" // CMock 自动生成的 mock
#include "event_queue.h"
static EventQueue queue;
void setUp(void) {
// 每个测试用例运行前执行
event_queue_init(&queue);
}
void tearDown(void) {
// 每个测试用例运行后执行
// 可以在这里清理全局状态
}
void test_push_should_start_timer_when_queue_was_empty(void) {
// 期望 timer_start 被调用一次,参数为 100ms
timer_start_Expect(100);
// 执行被测函数
Event event = { .id = EVT_BUTTON_PRESS, .data = NULL };
bool result = event_queue_push(&queue, &event);
// 断言
TEST_ASSERT_TRUE(result);
TEST_ASSERT_EQUAL(1, event_queue_count(&queue));
}
void test_push_should_fail_when_queue_full(void) {
// 先填满队列
for (int i = 0; i < QUEUE_MAX_SIZE; i++) {
Event e = { .id = i, .data = NULL };
event_queue_push(&queue, &e);
}
// 再 push 一个应该失败
Event overflow = { .id = EVT_OVERFLOW, .data = NULL };
bool result = event_queue_push(&queue, &overflow);
TEST_ASSERT_FALSE(result);
}
跑测试:
ceedling test:test_event_queue
你会看到类似这样的输出:
Test 'test_event_queue.c'
- test_push_should_start_timer_when_queue_was_empty: PASS
- test_push_should_fail_when_queue_full: PASS
-------------------
2 tests, 2 passed, 0 failed
小技巧:CMock 生成的 timer_start_Expect(100) 不仅会模拟函数行为,还会在测试结束时自动检查这个函数是否真的被调用了。如果被测代码没调 timer_start,测试会直接 FAIL。这比手动写桩代码省了至少 60% 的工作量。
3.4 知识体系总览
下面这张图把三者的关系画清楚了。我每次给团队做培训都会先展示这张图——它帮你理解「谁依赖谁」「谁生成谁」。
3.5 常见问题与避坑
配置过程中有几个地方特别容易翻车,我列出来供你参考:
- 头文件路径遗漏:CMock 解析头文件时,如果找不到依赖的头,会直接报错退出。我建议在
project.yml的:cmock:includes里显式列出所有公共头文件。 - Mock 函数命名冲突:如果你的生产代码里有一个函数叫
timer_start,CMock 会生成mock_timer.h,里面包含timer_start_Expect。注意不要在测试文件里再手动定义同名函数。 - 静态函数无法 mock:CMock 只能 mock 头文件里声明的函数。静态函数(static)需要你手动写桩,或者把测试需要的函数改成非静态并加上
#ifdef UNIT_TEST保护。 - 链接顺序问题:如果测试链接时出现重复定义,检查
project.yml里的:paths:source是否包含了不应该被编译的源文件。我习惯把测试专用的桩代码放在test/support/目录下,并在配置里单独引用。
特别提醒:不要在嵌入式目标板上直接跑 Ceedling 生成的测试二进制。Ceedling 默认用宿主机的 gcc 编译,跑在 PC 上。如果你想在目标板上跑,需要配置交叉编译器并在 :tools:test_linker 里指定链接脚本。不过我个人建议——先在 PC 上把逻辑测透,再在目标板上做集成验证,效率最高。
3.6 小结
Ceedling + Unity + CMock 这套组合,说白了就是给嵌入式 C 代码配了一套「自动化测试流水线」。Unity 提供断言能力,CMock 解决外部依赖模拟,Ceedling 把编译、运行、报告全包了。你只需要写好 project.yml,然后专心写测试用例就行。
我刚开始用的时候,光配置就折腾了两天。但一旦跑通第一个测试,后面就顺了。记住一个原则:配置一次,受益整个项目周期。别怕初期投入,这笔账绝对划算。