一、项目简介
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 等。
简化理解一下调用链(这里用一张图说明):
关键点:
所有断言最终都落到少数几个核心比较函数,比如:UnityAssertEqualIntNumberUnityAssertIntGreaterOrLessOrEqualNumberUnityAssertFloatsWithin / UnityAssertDoublesWithinUnityAssertEqualMemory 等字符串、数组、内存比较的“不同形态”,其实只是对参数的解释和循环控制不同,错误打印逻辑完全复用。
这种设计的好处是:对外提供大量的 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_DOUBLEUNITY_INCLUDE_DOUBLEUNITY_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.csrc/unity.hsrc/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 和老旧编译器环境。
702