3、单元测试框架:Unity测试框架介绍、在ESP-IDF中集成Unity、编写第一个单元测试用例
好,咱们进入第三章。单元测试,说白了就是给代码做「体检」。你写了一个函数,它到底跑得对不对?边界情况处理了没?内存有没有泄漏?这些问题,靠人眼review是看不全的。我个人的习惯是,写完一个模块,先不急着联调,先把单元测试跑通了再说。这样后面集成的时候,心里才有底。
在嵌入式领域,尤其是ESP32这种资源受限的环境下,选一个轻量级的测试框架很重要。Unity就是这么一个东西——它小、快、不占资源,而且跟ESP-IDF的集成非常顺滑。今天我们就把它掰开揉碎了讲清楚。
3.1 Unity测试框架介绍
Unity是什么?它是一个纯C语言的单元测试框架。你想想看,嵌入式开发大部分还是C语言,C++的测试框架比如Catch2、Google Test虽然功能强大,但在ESP32上跑起来有点「重」。Unity就一个.c和一个.h文件,核心代码不到2000行,编译出来可能就几KB。嗯,这才是嵌入式该有的样子。
Unity的核心概念其实就三个:
- 测试用例(Test Case):一个测试用例就是一个函数,用来验证一个特定的行为。比如「当输入为0时,函数返回0」。
- 测试组(Test Group):把相关的测试用例放在一起,比如「所有关于温度传感器的测试」。
- 断言(Assertion):这是测试的灵魂。Unity提供了丰富的断言宏,比如
TEST_ASSERT_EQUAL、TEST_ASSERT_TRUE、TEST_ASSERT_WITHIN等等。
我记得刚开始用Unity的时候,觉得它太「简陋」了,连个mock功能都没有。后来用久了才发现,这正是它的设计哲学——只做一件事,把它做好。mock你可以用CMock或者自己手写,Unity不越界。
Unity的典型断言宏一览:
| 宏名称 | 用途 |
|---|---|
TEST_ASSERT_EQUAL(expected, actual) |
判断两个整数是否相等 |
TEST_ASSERT_EQUAL_STRING(expected, actual) |
判断两个字符串是否相等 |
TEST_ASSERT_TRUE(condition) |
判断条件是否为真 |
TEST_ASSERT_FALSE(condition) |
判断条件是否为假 |
TEST_ASSERT_WITHIN(delta, expected, actual) |
判断实际值是否在期望值的±delta范围内(浮点比较必备) |
TEST_ASSERT_NULL(pointer) |
判断指针是否为NULL |
这里我要特别提一下TEST_ASSERT_WITHIN。做嵌入式开发,浮点数的比较是个坑。两个浮点数直接比相等,大概率会失败。我遇到过好几次,明明算法算出来是对的,测试就是不过。后来发现是浮点精度问题。用WITHIN给个容忍范围,世界就清净了。
3.2 在ESP-IDF中集成Unity
ESP-IDF从4.x版本开始,官方就把Unity集成进来了。你不用自己下载源码,也不用手动配置。说白了,ESP-IDF的组件注册系统已经帮你铺好了路。
集成步骤其实就三步:
- 创建测试目录:在你的项目根目录下,创建一个
test文件夹。ESP-IDF的构建系统会自动识别这个目录。 - 编写测试组件CMakeLists.txt:在
test目录下创建一个CMakeLists.txt文件,内容如下:
# test/CMakeLists.txt
idf_component_register(SRCS "test_my_module.c"
INCLUDE_DIRS "."
EMBED_TXTFILES "test_data.txt")
这里要注意,idf_component_register是ESP-IDF特有的注册方式。如果你有测试用的数据文件(比如模拟的传感器数据),可以用EMBED_TXTFILES把它嵌入到固件中。
- 添加Unity依赖:在
test目录下的CMakeLists.txt中,加上一行:
# 在 idf_component_register 之后添加
target_link_libraries(${COMPONENT_LIB} INTERFACE unity)
或者更简洁的方式,直接在idf_component_register里声明:
idf_component_register(SRCS "test_my_module.c"
INCLUDE_DIRS "."
REQUIRES unity)
我个人更推荐用REQUIRES的方式,因为ESP-IDF的依赖管理会自动帮你处理头文件路径和链接顺序。你想想看,如果手动链接,万一顺序错了,编译报错找半天,多浪费时间。
小技巧: 如果你想让测试代码和被测试代码分离得更干净,可以在main组件里写业务逻辑,在test组件里写测试。这样发布固件的时候,直接把test文件夹删掉就行,不会污染生产代码。
3.3 编写第一个单元测试用例
好,理论说完了,咱们动手写一个。假设我有一个简单的数学模块math_utils.c,里面有一个函数int add(int a, int b),功能就是返回两个数的和。这函数太简单了,但用来演示刚刚好。
第一步:准备被测试的代码
在main组件里,创建math_utils.h和math_utils.c:
// main/math_utils.h
#ifndef MATH_UTILS_H
#define MATH_UTILS_H
int add(int a, int b);
#endif
// main/math_utils.c
#include "math_utils.h"
int add(int a, int b) {
return a + b;
}
第二步:编写测试用例
在test目录下,创建test_math_utils.c:
// test/test_math_utils.c
#include "unity.h"
#include "math_utils.h"
// 这个函数会在每个测试用例之前执行,用来初始化
void setUp(void) {
// 这里可以放一些初始化代码,比如重置全局变量
}
// 这个函数会在每个测试用例之后执行,用来清理
void tearDown(void) {
// 这里可以放一些清理代码,比如释放内存
}
// 测试用例1:正数相加
void test_add_positive_numbers(void) {
int result = add(2, 3);
TEST_ASSERT_EQUAL(5, result);
}
// 测试用例2:负数相加
void test_add_negative_numbers(void) {
int result = add(-2, -3);
TEST_ASSERT_EQUAL(-5, result);
}
// 测试用例3:正负数混合
void test_add_mixed_numbers(void) {
int result = add(5, -3);
TEST_ASSERT_EQUAL(2, result);
}
// 测试用例4:边界值——0
void test_add_with_zero(void) {
TEST_ASSERT_EQUAL(5, add(5, 0));
TEST_ASSERT_EQUAL(0, add(0, 0));
}
// 测试用例5:整数溢出(这个要小心)
void test_add_overflow(void) {
// INT_MAX + 1 在C语言中是未定义行为,但我们可以验证
int result = add(2147483647, 1);
// 期望结果是负数(溢出回绕)
TEST_ASSERT_EQUAL(-2147483648, result);
}
这里我要多说一句。你看test_add_overflow这个用例,它测试的是整数溢出。我在项目中遇到过,有人觉得「溢出不会发生」,结果产品在极端工况下跑了好几天,突然数据全乱了。最后定位到是一个累加器溢出了。所以,边界值测试一定要写,哪怕你觉得「不可能」。
第三步:注册测试用例
在同一个文件里,我们需要告诉Unity哪些函数是测试用例。Unity要求我们提供一个app_main函数(在ESP-IDF中,测试也是以组件形式运行的):
// 继续在 test_math_utils.c 中
void app_main(void) {
// 初始化Unity框架
UNITY_BEGIN();
// 运行测试用例
RUN_TEST(test_add_positive_numbers);
RUN_TEST(test_add_negative_numbers);
RUN_TEST(test_add_mixed_numbers);
RUN_TEST(test_add_with_zero);
RUN_TEST(test_add_overflow);
// 结束测试,输出结果
UNITY_END();
}
第四步:编译和运行
在终端里,进入你的ESP-IDF项目目录,执行:
idf.py -p /dev/ttyUSB0 flash monitor
注意,这里要指定你的串口端口。ESP-IDF的测试框架会把测试结果打印到串口上。你会看到类似这样的输出:
Unity test run 1 of 1
-----------------------
test_add_positive_numbers PASS
test_add_negative_numbers PASS
test_add_mixed_numbers PASS
test_add_with_zero PASS
test_add_overflow PASS
-----------------------
5 Tests 0 Failures 0 Ignored
OK
看到这个「OK」,心里就踏实了。
避坑指南: 我曾经犯过一个低级错误——在setUp函数里申请了动态内存,但在tearDown里忘了释放。结果跑完一个测试用例,内存泄漏一点,跑完十个,堆空间就炸了。ESP32的堆空间本来就不大,这种问题很容易被忽略。所以,setUp和tearDown一定要成对使用,申请了什么,就释放什么。
3.4 测试的组织与运行技巧
当你的测试用例多起来之后,怎么组织它们就成了一个问题。我个人的习惯是:
- 按模块分文件:每个模块一个测试文件,比如
test_math_utils.c、test_sensor.c、test_wifi.c。 - 按功能分组:在同一个文件里,用注释把测试用例分成几个组,比如「基础运算测试」、「边界条件测试」、「异常输入测试」。
- 使用测试标签:Unity支持给测试用例打标签,比如
TEST_GROUP_RUNNER,但ESP-IDF的集成方式稍微有点不同。如果你需要选择性运行某些测试,可以在app_main里用条件编译来控制。
举个例子,假设你只想跑「边界条件测试」,可以这样:
void app_main(void) {
UNITY_BEGIN();
// 只运行边界条件相关的测试
RUN_TEST(test_add_with_zero);
RUN_TEST(test_add_overflow);
UNITY_END();
}
嗯,虽然粗暴,但有效。对于小项目来说,这种方式完全够用。如果你需要更精细的控制,可以考虑用TEST_GROUP和TEST_GROUP_RUNNER,但那套宏用起来稍微有点绕,我建议初学者先别碰。
好了,这一章的内容就到这里。Unity框架的核心就是「断言+用例+运行器」,你掌握了这三个东西,就能写出有效的单元测试。下一章我们会讲更高级的测试技巧,比如如何mock硬件依赖、如何测试中断处理函数。到时候你会发现,有了单元测试这把「手术刀」,再复杂的代码也能被拆解成一个个可验证的小块。