...

一、项目简介

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);

这些宏内部并不直接实现逻辑,而是做三件事:

  1. 捕获调用位置:宏里用 __LINE__,把当前源文件行号传给内部实现。
  2. 封装显示风格:比如“按有符号整数打印”还是“按十六进制打印”。
  3. 转发到内部函数:如 UnityAssertEqualIntNumberUnityAssertFloatsWithin 等。

简化理解一下调用链(这里用一张图说明):

关键点:

  • 所有断言最终都落到少数几个核心比较函数,比如:
    • 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 决定:

    • 统计到 TestFailuresTestIgnores
    • 输出一行当前测试结果;
    • 清理标志。
  • UnityEnd
    输出整体统计信息(总测试/失败/忽略),打印最终 OKFAIL,并返回失败数(给 main() 作为 exit code)。

对嵌入式来说,这一套只依赖 UNITY_OUTPUT_CHAR 宏(默认是 putchar),你可以重定义成:

  • 串口发送函数;
  • SWO 输出;
  • 环形缓冲区写日志。

4. 配置选项:按需裁剪 + 类型适配

unity.h 的注释里,你能看到大量 #define UNITY_... 的配置说明,例如:

  • 整数宽度与 64 位支持:
    • UNITY_SUPPORT_64UNITY_INT_WIDTHUNITY_LONG_WIDTHUNITY_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_TYPEUNITY_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(23));
}

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,实际上连 mainUnityDefaultTestRun 调用都可以自动生成,你只需写 test_... 函数。

3. 断言失败时发生了什么?

比如第二个测试里我们故意写错:

TEST_ASSERT_EQUAL_INT(0, add(2-3)); // 实际结果是 -1

在内部流程上会大致经历:

  1. TEST_ASSERT_EQUAL_INT 宏把 __LINE__、期望值、实际值传给内部函数。
  2. UnityAssertEqualIntNumber 检测到不相等:
    • 通过 UnityTestResultsFailBegin 打印前缀:文件:行号:测试名:FAIL:
    • 打印 ExpectedWas
    • 调用 UNITY_FAIL_AND_BAIL
      • 设置 Unity.CurrentTestFailed = 1
      • 刷新输出;
      • TEST_ABORT() 提前跳出当前测试。

这样后面的语句不会继续执行,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(23));
}

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(30));
    TEST_ASSERT_EQUAL_INT(-3, add(-30));
}

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 和老旧编译器环境。

......

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐