| 编辑推荐: |
文章主要介绍了一个专为 C 语言设计的轻量级单元测试框架 Unity,重点说明了它为什么适合嵌入式环境、核心原理以及如何在嵌入式项目中使用。,希望对你的学习有帮助。
本文来自于嵌入式大杂烩,由火龙果软件Alice编辑、推荐。 |
|
一、项目简介
Unity 是一个专门为 C 语言设计的轻量级单元测试框架,核心目标很简单: 让你在任何 C 编译器、任何嵌入式工具链里,都能方便地写单元测试 。
https://github.com/ThrowTheSwitch/Unity
MIT license
|
它解决的典型问题是:
- 嵌入式环境缺少标准测试框架 :很多 MCU 工具链没有 gtest/catch2 这类 C++ 框架,也不方便引入复杂依赖。
- 资源受限 :Flash、RAM 都很紧张,测试框架必须足够小、足够“可裁剪”。
- 多种构建系统 :Make、CMake、Meson、PlatformIO 甚至自研脚本都要能无痛接入。
Unity 的设计就几句话:
- 核心只有 unity.c + unity.h + unity_internals.h , 一个 C 文件、一对头文件 。
- 全部通过宏和编译选项配置, 0 运行时动态分配 。
- 输出风格简单,可重定向,方便串口、日志解析器、CI 工具处理。
二、核心原理
1. 整体架构与目录结构
先用一个整体架构图,看清 Unity 在项目中的位置。
从仓库结构看,几个关键目录:
Unity/ ├── src/ │ ├── unity.c # 核心实现:断言、输出、测试执行控制 │ ├── unity.h # 对外断言宏与 API(TEST_ASSERT_*) │ └── unity_internals.h # 内部数据结构与内部接口 ├── extras/ │ ├── fixture/ # 测试夹具扩展:测试组、测试套件等 │ ├── memory/ # 内存分配跟踪扩展:检测 malloc/free 泄漏 │ ├── bdd/ # BDD 风格支持 │ └── eclipse/ # Eclipse 等 IDE 集成支持 ├── auto/ │ ├── generate_test_runner.rb # 自动生成 test runner │ ├── parse_output.rb # 解析测试输出 │ ├── stylize_as_junit.py # 转换为 JUnit 报告(Python 版本) │ ├── stylize_as_junit.rb # 转换为 JUnit 报告(Ruby 版本) │ └── extract_version.py # 从 src/unity.h 抽取版本号(供构建系统使用) ├── examples/ # 各种示例工程(不同构建方式与配置) └── test/ # Unity 自身的测试工程(自举测试)
|
如果你把 Unity 当成第三方库引入自己的嵌入式项目,通常只需要:
- 拿 src/ + 需要的 extras/ 子目录;
- 用自己的 Make/CMake 项目把 unity.c 编进来;
- 再决定是否使用 auto/ 里的脚本来自动生成 test runner。
2. 断言宏与内部实现的解耦
Unity 的对外 API 全在 unity.h ,典型的断言宏长这样:
TEST_ASSERT_EQUAL_INT(expected, actual); TEST_ASSERT_FLOAT_WITHIN(delta, expected, actual); TEST_ASSERT_NULL(ptr);
|
这些宏内部并不直接实现逻辑,而是做三件事:
- 捕获调用位置 :宏里用 __LINE__ ,把当前源文件行号传给内部实现。
- 封装显示风格 :比如“按有符号整数打印”还是“按十六进制打印”。
- 转发到内部函数 :如 UnityAssertEqualIntNumber 、 UnityAssertFloatsWithin 等。
简化理解一下调用链(这里用一张图说明):
关键点:
- 所有断言最终都落到少数几个核心比较函数 ,比如:
- UnityAssertEqualIntNumber
- UnityAssertIntGreaterOrLessOrEqualNumber
- UnityAssertFloatsWithin / UnityAssertDoublesWithin
- UnityAssertEqualMemory 等
- 字符串、数组、内存比较的“不同形态”,其实只是 对参数的解释和循环控制不同 ,错误打印逻辑完全复用。
这种设计的好处是:
- 对外提供大量的 TEST_ASSERT_* 宏,但内核函数不多, 维护成本低 。
- 宏透传行号、类型信息,内部统一处理打印格式, 错误信息保持一致风格 。
3. 全局状态与测试执行控制
Unity 用一个全局结构体 UNITY_STORAGE_T Unity; 来管理整个测试生命周期,包含:
- 当前测试名、文件名、行号;
- 测试总数、失败数、忽略数;
- 当前测试的失败/忽略标志;
- 可选的 detail stack(用于扩展的上下文信息)。
测试执行是一个“状态机+输出”的过程,用另一张图看得更清楚:
几个关键 API:
- UnityBegin
初始化全局状态、输出头部信息。
- UnityDefaultTestRun (如果你不用自定义 runner 就会用到)
- 填充 Unity.CurrentTestName / LineNumber ;
- 使用 TEST_PROTECT() 对 setUp 、测试函数、 tearDown 进行保护;
- 调用 UnityConcludeTest 做收尾。
- UnityConcludeTest
根据 CurrentTestFailed/CurrentTestIgnored 决定:
- 统计到 TestFailures 或 TestIgnores ;
- 输出一行当前测试结果;
- 清理标志。
- UnityEnd
输出整体统计信息(总测试/失败/忽略),打印最终 OK 或 FAIL ,并返回失败数(给 main() 作为 exit code)。
对嵌入式来说,这一套只依赖 UNITY_OUTPUT_CHAR 宏(默认是 putchar ),你可以重定义成:
- 串口发送函数;
- SWO 输出;
- 环形缓冲区写日志。
4. 配置选项:按需裁剪 + 类型适配
在 unity.h 的注释里,你能看到大量 #define UNITY_... 的配置说明,例如:
- 整数宽度与 64 位支持:
- UNITY_SUPPORT_64 , UNITY_INT_WIDTH , UNITY_LONG_WIDTH , UNITY_POINTER_WIDTH 。
- 浮点相关:
- UNITY_EXCLUDE_FLOAT / UNITY_EXCLUDE_DOUBLE
- UNITY_INCLUDE_DOUBLE
- UNITY_FLOAT_PRECISION / UNITY_DOUBLE_PRECISION
- 输出行为:
- UNITY_OUTPUT_CHAR(a) 自定义输出字符函数。
- UNITY_DIFFERENTIATE_FINAL_FAIL 控制最后的 FAILED 字样。
- 计数器类型:
- UNITY_LINE_TYPE , UNITY_COUNTER_TYPE 可换成更大的整数类型,用于超大工程。
整体设计思路是:
- 所有配置都在编译期决定 ,靠宏开关,避免运行期分支和多余的数据。
- 缺什么就 #define UNITY_EXCLUDE_* 掉,特别适合 ROM 很紧的 MCU。
三、实战
下面用一个典型 的最小例子,演示如何把 Unity 跑起来。假设你有一个简单模块 calc.c :
// calc.c int add(int a, int b) { return a + b; }
|
1. 引入 Unity 核心
在你的工程中,加入以下文件:
- src/unity.c
- src/unity.h
- src/unity_internals.h (只被 unity.c include)
在编译脚本里把 unity.c 编进去即可(Make/CMake/Meson 都行)。
2. 写一个简单测试文件
// test_calc.c #include "unity.h" #include "calc.h"
void setUp(void) { // 每个测试前的初始化 }
void tearDown(void) { // 每个测试后的清理 }
void test_add_should_sum_two_positive_numbers(void) { TEST_ASSERT_EQUAL_INT(5, add(2, 3)); }
void test_add_should_handle_negative(void) { TEST_ASSERT_EQUAL_INT(-1, add(2, -3)); }
int main(void) { UnityBegin("test_calc.c");
UnityDefaultTestRun(test_add_should_sum_two_positive_numbers, "test_add_should_sum_two_positive_numbers", __LINE__);
UnityDefaultTestRun(test_add_should_handle_negative, "test_add_should_handle_negative", __LINE__);
return UnityEnd(); }
|
实际运行结果:
注意几点:
- setUp/tearDown 是 Unity 约定的钩子,必须有定义(即使为空)。
- 每个测试函数名自定义即可,但建议统一 test_ 前缀。
- UnityDefaultTestRun 会负责调用 setUp/test/tearDown 并处理异常。
如果你使用 auto/generate_test_runner.rb ,实际上连 main 和 UnityDefaultTestRun 调用都可以自动生成,你只需写 test_... 函数。
3. 断言失败时发生了什么?
比如第二个测试里我们故意写错:
TEST_ASSERT_EQUAL_INT(0, add(2, -3)); // 实际结果是 -1
|
在内部流程上会大致经历:
- TEST_ASSERT_EQUAL_INT 宏把 __LINE__ 、期望值、实际值传给内部函数。
- UnityAssertEqualIntNumber 检测到不相等:
- 设置 Unity.CurrentTestFailed = 1 ;
- 刷新输出;
- TEST_ABORT() 提前跳出当前测试。
- 通过 UnityTestResultsFailBegin 打印前缀: 文件:行号:测试名:FAIL:
- 打印 Expected 和 Was 。
- 调用 UNITY_FAIL_AND_BAIL :
这样后面的语句不会继续执行, UnityConcludeTest 会把这个测试计入失败,并输出一行统计。
这个“遇错即跳出”的模式是专门为嵌入式考虑的:
- 避免在故障状态下继续执行大量断言;
- 依赖 TEST_PROTECT() 的实现(通常是 setjmp/longjmp )来保证 tearDown 仍然有机会执行。
4. 使用 fixture 扩展做“测试组”
如果你引入 extras/fixture/src/unity_fixture.c 和对应头文件,可以用更高级一点的写法:
- 定义测试组( TEST_GROUP )
- 使用 TEST_SETUP/TEST_TEAR_DOWN
- 用 TEST 宏定义测试用例
- 通过 RUN_TEST_GROUP 统一跑整组测试
示例代码如下:
#include "unity.h" #include "unity_fixture.h" #include "calc.h"
/* 第一个测试组:基础加法场景 */ TEST_GROUP(CalcBasic);
TEST_SETUP(CalcBasic) {} TEST_TEAR_DOWN(CalcBasic) {}
TEST(CalcBasic, AddTwoPositiveNumbers) { TEST_ASSERT_EQUAL_INT(5, add(2, 3)); }
TEST(CalcBasic, AddWithNegative) { TEST_ASSERT_EQUAL_INT(-1, add(2, -3)); }
TEST_GROUP_RUNNER(CalcBasic) { RUN_TEST_CASE(CalcBasic, AddTwoPositiveNumbers); RUN_TEST_CASE(CalcBasic, AddWithNegative); }
/* 第二个测试组:边界和特殊场景 */ TEST_GROUP(CalcEdge);
TEST_SETUP(CalcEdge) {} TEST_TEAR_DOWN(CalcEdge) {}
TEST(CalcEdge, AddWithZero) { TEST_ASSERT_EQUAL_INT(3, add(3, 0)); TEST_ASSERT_EQUAL_INT(-3, add(-3, 0)); }
TEST(CalcEdge, AddSymmetricNegPos) { TEST_ASSERT_EQUAL_INT(0, add(5, -5)); }
TEST_GROUP_RUNNER(CalcEdge) { RUN_TEST_CASE(CalcEdge, AddWithZero); RUN_TEST_CASE(CalcEdge, AddSymmetricNegPos); }
static void RunAllTests(void) { RUN_TEST_GROUP(CalcBasic); RUN_TEST_GROUP(CalcEdge); }
int main(int argc, const char* argv[]) { return UnityMain(argc, argv, RunAllTests); }
|
运行结果如:
这层扩展本质上是:
- 在 Unity 核心的基础上,包装了一套更接近 “xUnit” 的分组模型;
- 仍然通过 Unity 的断言和 UnityBegin/UnityEnd 系列函数完成底层统计和输出(由 UnityMain 封装)。
对于中大型嵌入式项目,建议用 fixture 扩展来做“模块级测试集”,更易维护,也更接近常见的 xUnit 测试习惯。
四、总结
Unity 单个 .c + 两个 .h 完成所有功能,没有动态分配,没有对标准库复杂依赖,非常适合 MCU 和老旧编译器环境。
|