资讯动态

用Unity框架为C语言工程搭建自动化单元测试与回归防线

发布时间:2026/10/17 6:05:30 来源:尧图企业网站定制
简介一份面向 C 语言开发者的自动测试入门资料围绕轻量级测试框架 Unity 展开重点解决测试代码与产品代码如何分离、测试容器如何生成、如何通过 make 一键编译运行等问题。文档首先给出 proj/src/test 的标准工程结构并说明 Unity 框架可作为独立模块编译、以头文件方式提供 API 供上层调用的设计思路随后演示从复制 Unity 源码、编写测试用例到使用 Ruby 自动生成 Runner 的完整流程还穿插了 Makefile 的简单写法与 PASS/FAIL/IGNORE 结果判定。文档会逐一拆解 demo.c、demo_test_group1.c、demo_test_group1_Runner.c 等文件关系便于读者理解模块源码、测试夹具和自动生成容器各自承担的职责。资料以单个 doc 文档形式提供大小约 305KB目前已有 420 人学习。对于刚接触 C 语言测试、希望低成本搭建自动测试流程的开发者这份文档能帮助快速建立可运行的最小测试工程并了解框架的扩展方向与实际应用场景。1. 使用Unity做C语言自动测试先分清这是测试框架不是游戏引擎几千行C代码改到深夜第二天一早发现昨天的改动把老功能搞崩了却想不起是哪个函数出的问题——这就是我当初决定用Unity做C语言自动测试的直接原因。Unity是C语言生态里一个很轻量的单元测试框架不是做游戏的那个Unity它只由几个.c和.h文件组成不需要装任何IDE配合Ruby脚本自动生成测试容器就能在命令行里跑完所有测试用例输出PASS、FAIL、IGNORE三种结果。它解决的核心问题是把测试代码和产品代码干净分开让回归测试成为一条make命令的事。适合嵌入式开发、驱动模块、工具库这类C工程也适合想让老项目快速补上测试的团队。注意这里说的测试容器就是那个含有main函数的*_Runner.c文件它由脚本自动生成你不用手写main入口。整个框架的学习曲线很平缓哪怕你之前只用过printf调bug也能在一个小时内把第一个测试跑起来。2. 工程目录与模块划分把产品代码和测试代码彻底拆开的正确姿势2.1 标准目录proj 下分 src 和 test我第一次用Unity时犯过一个错把测试用例和产品代码堆在同一个目录里结果Makefile越写越乱最后不得不推倒重来。Unity官方推荐的思路其实很简单就用proj做工程根目录下面分src和test两个兄弟目录proj/ ├── src/ │ ├── demo.c │ └── demo.h └── test/ ├── unity/ │ ├── unity.c │ ├── unity.h │ └── auto/ │ └── generate_test_runner.rb ├── demo_test_group1.c ├── demo_test_group1_Runner.c ├── demo_test_group2.c ├── demo_test_group2_Runner.c └── Makefilesrc放产品代码test放测试代码Unity框架解压后直接放在test/unity下。这样做的第一个好处是编译时源文件列表很清晰产品代码只编src里的.c测试代码只编test里的.c两个世界互不干扰。第二个好处是发布时只需要把src目录拷出去test目录留在本地做回归不会污染交付物。这里要特别说一下demo_test_group1_Runner.c这个文件。它里面含有main函数是整个测试程序的入口但它不是手写的而是通过Ruby脚本根据demo_test_group1.c自动生成的。所以你在版本管理的时候可以直接忽略*_Runner.c反正随时能重新生成避免提交冲突。2.2 Unity框架独立编译当成交互驱动来用原文档里提到unity 作为一个模块另外编译可以当做交换驱动一样的东西单独编译然后提供.h 即提供 api 给上层调用。这句话值得展开Unity本身只需要编译unity.c这一个源文件产物是unity.o测试代码只要包含unity.h就能使用它的API。这和嵌入式里的驱动模块思路很像——驱动单独编译对外只暴露头文件上层不知道内部实现。实际Makefile里我们不会每次都重新去编译unity.o而是把它放进依赖里只有unity.c变化时才重新编译。这样跑几十个测试group每次编译提升明显。我习惯把Unity相关的编译规则单独写UNITY_SRC unity/unity.c UNITY_OBJ unity/unity.o $(UNITY_OBJ): $(UNITY_SRC) $(CC) $(CFLAGS) -c $ -o $注意这里的CFLAGS要包含-Iunity否则unity.c自己include unity.h都找不到。很多入门教程把unity.c直接和测试源文件混在一起编译也能跑但一旦工程里有多个group每次全量编译的时间就会翻倍。单独编译Unity后整个test目录的编译时间能压到一两秒。2.3 真实工程里常见的 module / src / inc / test 分层前面说的是单个demo工程真正的中型C工程一个proj根本不够用。原文档给出了一个可以落地的扩展结构moudule/ ├── src/ # 本模块的源文件 ├── inc/ # 本模块暴露给外部调用的头文件 └── test/ ├── unity/ # 依赖Unity框架 ├── module_test.c └── module_test_Runner.c每个模块自带src、inc和testUnity作为一个公共模块单独编译类似交换驱动的角色——每个模块编译测试时都include unity.h和本模块的inc头文件。这样的好处是测试代码只依赖模块的对外接口inc而不直接include src里的内部实现文件接口变了测试马上编译失败这其实是强制你做模块边界。我做过一个类似的项目当时把公共工具函数放在一个common模块里测试代码全都include common/inc下的头文件后来重构公共函数时测试用例跟着一起改改完跑一遍make test所有回归在十分钟内完成这比手工点界面靠谱太多。2.4 include路径与头文件依赖常见误用测试源文件开头一般长这样#include unity.h #include demo.h这两个include分别来自test/unity目录和src目录。在编译时如果没把-I../src -Iunity加进去gcc会报demo.h: No such file or directory。我见过有人为了省事直接把demo.h拷贝一份到test目录结果产品代码改了测试还在用旧头文件查了半天才发现是头文件过期。正确做法是用编译参数指定路径保证测试始终引用src里的最新头文件。Makefile里的CFLAGS可以这样写CFLAGS -I../src -Iunity -I.其中-I.是为了让编译器找到当前目录下的测试源文件里include的其它测试头文件比如某些group共用的头文件。注意路径是相对Makefile所在目录的如果Makefile在test下-I../src指的就是proj/src。如果目录结构再深一层就用-I../../src这种相对路径写多了容易乱我的习惯是统一在每组测试的Makefile里维护一份相对路径清单并在文件头部注释清楚当前路径。3. 自动生成测试容器Ruby脚本生成Runner的流程与原理3.1 先装好Ruby生成Runner的前置条件Unity自带的generate_test_runner.rb脚本是Ruby写的所以第一步是确认机器上有Ruby环境。在常见的Linux发行版上一行命令的事sudo apt-get install ruby装完后验证一下版本ruby -v输出类似ruby 3.x.x就可以。这块有个坑如果机器上同时装了多个Ruby版本比如系统自带和RVM安装的建议直接用which ruby看清楚当前用的是哪个否则脚本跑到一半报require: cannot load such file多半就是Ruby版本或GEM路径不对。Unity这个脚本本身不依赖额外gem纯粹是标准库操作所以只要能跑ruby -v就够。3.2 跑通一次Runner生成两条命令的事按原文档的操作步骤进入test目录后执行cd /root/TDD/proj/test ruby unity/auto/generate_test_runner.rb demo_test_group1.c ruby unity/auto/generate_test_runner.rb demo_test_group2.c这两条命令会扫描demo_test_group1.c和demo_test_group2.c里面的测试函数自动生成demo_test_group1_Runner.c和demo_test_group2_Runner.c。执行完之后用ls看一下两个带_Runner字样的文件应该出现在test目录下。脚本扫描的标准是看测试源文件里是否出现了形如test_开头的函数以及Unity的系列宏TEST_ASSERT、TEST_IGNORE等。它不需要你去手动维护一个测试函数列表新增一个测试用例后重新跑一遍脚本Runner自动带上新函数。这个自动化让我养成了写完用例先跑一次ruby脚本再make的肌肉记忆不然新用例永远不在可执行文件里。3.3 Runner文件内部到底长什么样生成的Runner文件内容其实非常有规律核心就是把测试函数逐个挂到main函数上。简化后相当于这样#include unity.h void setUp(void) {} void tearDown(void) {} int main(void) { UNITY_BEGIN(); RUN_TEST(test_demo_add); RUN_TEST(test_demo_sub); RUN_TEST(test_demo_multiply); return UNITY_END(); }RUN_TEST宏的真正定义在unity.h里展开后大致是调用传入的测试函数并在函数内出现断言失败时记录失败信息但不中断整个main流程。所以一个测试函数里可以有多个TEST_ASSERT只要有一个失败这个测试就被标记为FAIL后面的测试仍然继续跑。注意setUp和tearDown这两个函数是每个测试用例执行前和执行后调用的夹具函数即使里面什么都不写也必须存在否则链接会报未定义引用。它们适合放初始化、释放资源之类的公共逻辑后面第5章会专门讲那里容易踩坑。3.4 多测试组与多.out什么时候会生成两个可执行文件原文档里有一句若一个 group 则只一个.out意思是每个group对应一个Runner和一条可执行文件。demo里有两个group所以编译会产出demo_test_group1.out和demo_test_group2.out。为什么要分group一般是为了按模块或按功能划分测试维度group1放基础接口测试group2放边界条件测试这样某个group挂了能立刻定位到对应的模块。实际工程中我建议一个模块至少一个group。group粒度太粗几十个测试堆在一个文件里失败后看输出要翻很久粒度太细比如一个函数一个groupMakefile会膨胀到没法维护。我的习惯是每个源文件对应一个group比如demo.c对应demo_test_group1.c另一个复杂函数多的模块对应demo_test_group2.c。生成Runner时也用同一个Ruby脚本只是传入的.c文件名不同脚本本身不做任何修改。如果你只有一个可执行文件的需求那就不要拆group单组就够了Makefile也简单不少。4. 编译与运行Makefile写法与PASS/FAIL/IGNORE结果解读4.1 最简Makefile一个group怎么编译原文档说只编译一个 group最简单的 makefile 如下即源文件有 demo.c 产品代码文件 unity.c unity 文件 demo_test_group1.c 测试代码 demo_test_group1_Runner.c 测试容器含有 main 函数。我把它展开成可用的版本CC gcc CFLAGS -I. -I../src -Iunity OBJS ../src/demo.o unity/unity.o demo_test_group1.o demo_test_group1_Runner.o TARGET demo_test_group1.out $(TARGET): $(OBJS) $(CC) -o $ $(OBJS) %.o: %.c $(CC) $(CFLAGS) -c $ -o $ demo_test_group1_Runner.c: demo_test_group1.c ruby unity/auto/generate_test_runner.rb $ clean: rm -f *.o *.out demo_test_group1_Runner.c逻辑说明OBJS里四个目标文件分别对应产品代码、Unity框架、测试代码和测试容器。编译时先通过%.o: %.c规则生成每个.o文件最后把四个.o链接成可执行文件。Runner文件是作为依赖存在的如果它不存在就会触发ruby脚本重新生成所以第一次make时顺序是生成Runner → 编译各.o → 链接。参数说明-I../src让编译器能找到demo.h-Iunity找到unity.h-I.找到当前目录下其它头文件。-c只编译不链接$表示依赖列表中的第一个文件$表示目标文件。clean目标用于清理注意也清理掉自动生成的Runner文件避免提交到版本库里。4.2 多group与自动化把运行写进Makefile的利与弊当有两个group时可以这样扩展TARGETS demo_test_group1.out demo_test_group2.out all: $(TARGETS) ./demo_test_group1.out ./demo_test_group2.out这里把运行命令直接写在all目标里编译完紧接着就运行测试。省去了手动输入两个.out文件名的操作做到真正的一步到位。但这里有个非常隐蔽的坑如果测试用例中有FAILtest.out的返回码是非0的make会认为这条命令失败并终止后续规则。原文档特意提醒过这个现象test报错的返回值会导致make报错但其实编译是正确的。解决思路有两条。一条是接受这个行为把make all作为要么全过要么报错的严格门禁适合用在CI里。另一条是分开编译和运行跑测试时手动执行比如test: all ./demo_test_group1.out || true ./demo_test_group2.out || true用|| true吞掉非零返回码make就不会中断但你也不会得到准确的失败信息适合只想快速看一眼结果的场景。我推荐在本地开发用后者在CI里用前者。提示如果本地调试只想快速看结果可以用|| true吞掉测试进程的非零返回码但CI环境里千万别这么做否则测试失败也会被当成成功。4.3 结果输出三种状态的准确含义Unity运行完会打印类似这样的输出test_demo_add: PASS test_demo_sub: FAIL test_demo_multiply: IGNORE ----------------------- 2 Tests 1 Failures 1 IgnoredPASS表示该测试函数里的所有TEST_ASSERT都通过FAIL表示至少一个断言失败Unity会打印出失败的文件、行号和期望值IGNORE表示测试代码里调用了TEST_IGNORE()或函数被标记为忽略常见于功能还没实现完、或者当前环境不支持的用例。三种状态在自动化脚本里的意义完全不同。PASS和IGNORE的返回码不会让make失败只有FAIL会让测试进程返回非零。所以你在Makefile里看到的报错恰恰是测试在正常干活真正需要注意的是到底哪个用例FAIL了而不是make那一行红字。5. 避坑与常见问题五个让新手夜不能寐的坑5.1 make报错但编译其实成功返回码的玄学现象执行make时终端出现大段报错信息但仔细看编译完成和链接成功字样也都在生成的.out文件也在。原因测试用例出现了FAIL导致测试进程返回非零退出码。make默认认为任何非零退出码都是失败于是把整个make过程标记为失败看起来就像编译挂了。解决先区分是编译期报错还是运行期报错。看报错信息里有没有error:——如果有说明是语法或链接问题修代码如果只有FAIL和Tests Failed字样说明测试真的发现了问题。这个报错是设计好的不是翻车。我在Makefile里把运行单独拆成一个目标比如make run这样编译结果和测试结果分开看不会被make的退出码带偏。5.2 Ruby脚本找不到路径与权限的坑现象执行ruby unity/auto/generate_test_runner.rb demo_test_group1.c时提示No such file or directory或者require: cannot load such file -- unity。原因常见是两个问题。一是当前目录不在test下二是unity目录的层级不对。脚本内部用的是相对路径读取测试文件如果你在proj根目录执行它它找不到test下的.c文件。解决先cd /root/TDD/proj/test再用ls unity/auto确认generate_test_runner.rb确实存在。还有一个隐蔽问题如果是从Windows把Unity源码拷到Linux文件里可能带CRLF换行符Ruby脚本会报bad interpreter用dos2unix unity/auto/generate_test_runner.rb修复即可。5.3 生成的Runner里没有执行我的测试用例现象Runner文件生成了编译也过了但输出里根本没有自己新写的那个测试函数。原因Ruby脚本认测试函数是有规则的。默认情况下它扫描的是以test或spec开头的函数名如果你的测试函数叫check_buffer或者verify_add脚本不会识别自然不进Runner。解决写测试用例时统一用test_前缀比如test_demo_add。另外脚本还有命令行参数可以自定义前缀比如-f check_但我的建议是别做特殊定制规规矩矩用test_因为Unity的官方文档、示例代码和大部分同事的习惯都是这个前缀。5.4 链接阶段出现重复定义产品代码被编了两次现象链接时报duplicate symbol main或者duplicate symbol test_demo_add而且指向的.obj文件各不相同。原因Makefile里把product code同时放在了OBJS列表和测试目标的依赖规则里例如既写了../src/demo.o又通过通配符%.o: %.c把src目录下一个无关的.c也编译进来了。Unity的Runner里已经有一个main函数了如果你把产品代码里另一个带main的源文件也编进来必然重复定义。解决检查OBJS列表明确列出需要的源文件不要用类似$(wildcard ../src/*.c)这种通配方式。如果确实需要用通配符把产品代码和测试代码分目录编译用两个不同的OBJS变量拼接确保每个源文件只编译一次。5.5 IGNORE到底算不算测试通过现象测试用例里用了TEST_IGNORE()结果显示IGNORE但CI脚本却把这个group判定为成功。原因Unity对IGNORE的态度是跳过但不算失败所以进程返回码是0make不会报错。问题在于如果你的CI只看退出码IGNORE永远不会触发红灯被忽略的测试可能永远没人去完成。解决把所有测试都跑完且没有FAIL和所有测试都通过分开看。在CI里可以加一个解析输出的步骤统计IGNORE的次数超过阈值就警告。我在实际项目中把IGNORE的用例数量也作为发布指标的一部分确保每个迭代结束前IGNORE数量只能减少不能增加。6. 进阶玩法把Unity测试嵌进CI与参数化测试6.1 与CI集成让FAIL直接卡住代码合并CI里的核心诉求是测试结果直接决定能不能合代码。思路是用返回码做门禁在Makefile里加一个test目标把两个group的测试都跑一遍。一旦有FAIL测试进程返回非零make test就会报错CI任务跟着失败代码就合不进去。这里千万不要用|| true吞返回码那是本地调试不想被中断时才用的。为了保证新增用例一定被执行建议在test目标前先强制重新生成Runner文件比如把ruby生成步骤写在依赖里这样Runner永远是新的。6.2 参数化测试用宏批量生成用例C语言测试里最常见的重复劳动是同一个函数对多组输入各写一个用例比如加法函数要测正数、零和边界值。这类场景可以用宏参数化#define TEST_ADD_CASE(a, b, expected) \ void test_add_##a_##b(void) { \ TEST_ASSERT_EQUAL_INT(expected, add(a, b)); \ } TEST_ADD_CASE(1, 2, 3) TEST_ADD_CASE(2, 3, 5) TEST_ADD_CASE(0, 0, 0)宏里的##会把参数拼进函数名所以上面三行会分别生成test_add_1_2、test_add_2_3和test_add_0_0三个测试函数。Ruby脚本自动扫描到这些test_开头的函数并挂到main里你只需要维护宏调用那一行新增测试场景的成本降到最低。如果参数里要出现负数或特殊符号需要额外做一层名字转义这里不展开。从第一次被make的假报错骗过之后我就养成了一个习惯每次提交代码前不管改动多小都要完整跑一遍make test看一眼PASS总数有没有少、IGNORE有没有增加然后再看具体失败的用例是不是预期内的调整。这套流程帮我拦下过至少三次因为头文件改动导致的回归也让我对自动化测试能罩住多少风险这件事有了更实在的认知。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑