您可以捐助,支持我们的公益事业。

1元 10元 50元





认证码:  验证码,看不清楚?请点击刷新验证码 必填



  求知 文章 文库 Lib 视频 iPerson 课程 认证 咨询 工具 讲座 Model Center 汽车系统工程   模型库
    学习助手
会员   
   
FDE(前沿部署工程师)实践指南
11月11-12日 北京+线上
OCSMP 认证培训
11月19-20日 北京+线上
AI赋能产品管理实战
12月10-11日 北京+在线
     
   
 订阅
一个可应用于嵌入式的轻量级单元测试框架
 
  25   次浏览      3 次
 2026-11-12
 
编辑推荐:
文章主要介绍了一个专为 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);

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

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

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

  1. TEST_ASSERT_EQUAL_INT 宏把 __LINE__ 、期望值、实际值传给内部函数。
  2. 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 和老旧编译器环境。

   
25   次浏览       3 次
相关文章

微服务测试之单元测试
一篇图文带你了解白盒测试用例设计方法
全面的质量保障体系之回归测试策略
人工智能自动化测试探索
相关文档

自动化接口测试实践之路
jenkins持续集成测试
性能测试诊断分析与优化
性能测试实例
相关课程

持续集成测试最佳实践
自动化测试体系建设与最佳实践
测试架构的构建与应用实践
DevOps时代的测试技术与最佳实践

最新活动计划
AI时代的需求分析师培养 11-4[北京]
MBSE(基于模型的系统工程) 11-11[北京]
FDE(前沿部署工程师)实践 11-12[北京]
OCSMP 认证培训 11-19[在线]
UAF架构体系与实践 11-24[北京]
AI赋能产品管理实战 12-10[在线]
国产化数据库设计&优化 12-24[北京]
 
 
最新文章
大数据平台测试
微服务架构下的测试之道
从零开始掌握微服务软件测试
如何进行测试需求分析:从接收需求到用例设计
python_selenium自动化测试框架
最新课程
测试需求分析与测试用例设计
性能测试方法与技术
自动化测试框架设计高级实践
接口自动化测试方法与工具
软件测试方法与实践(贯穿案例)
更多...   
成功案例
某支付企业 单元测试与重构培训
北京 用户体验、可用性测试与评估
某军工研究单位 自动化测试方法、案例与工具
知名消费金融公司 探索性测试与测试分析
北京 航天科工某子公司 软件测试架构师
更多...