单元测试入门:CUnit/Unity框架、测试用例编写、测试套件组织
说实话,我刚入行那会儿,对单元测试是有点抵触的。总觉得「代码能跑就行,写测试不是浪费时间吗?」直到有一次,我花了两天改了一个模块,结果把另一个模块搞崩了,定位问题又花了一天。嗯,从那以后,我再也不敢不写测试了。
单元测试,说白了就是给函数写「体检报告」。你写了一个函数,它到底对不对?边界情况处理了吗?异常输入会崩溃吗?这些不能靠猜,得靠测试用例来验证。
为什么嵌入式开发更需要单元测试?
嵌入式软件有个特点:出问题很难复现。你在开发板上跑得好好的,一上产线就死机。或者客户用了三个月,突然某天系统重启了。这种问题,靠肉眼调试基本没戏。
我个人的习惯是:每个模块写完,先跑单元测试,再集成到主工程。这样能筛掉至少80%的低级错误。剩下的20%,往往是时序或硬件相关的问题,那属于集成测试的范畴。
两个主流框架:CUnit 和 Unity
C语言做单元测试,不像Java有JUnit那么成熟。但好在有两个框架用的人最多:CUnit 和 Unity。
| 特性 | CUnit | Unity |
|---|---|---|
| 安装方式 | 需要编译安装库 | 纯头文件,直接拷贝 |
| 依赖 | 需要标准C库 | 无依赖,适合裸机 |
| 输出格式 | 文本/XML | 文本 |
| 适合场景 | Linux/桌面开发 | 嵌入式/资源受限环境 |
| 学习曲线 | 中等 | 低 |
我个人更推荐 Unity 做嵌入式项目。为什么?因为它就一个 .c 和一个 .h 文件,拷贝到工程里就能用。我在一个STM32项目里用过,编译出来不到2KB,完全不占资源。
用 Unity 写第一个测试用例
假设我们有一个计算环形缓冲区剩余空间的函数:
// ring_buffer.h
typedef struct {
uint8_t *buffer;
int head;
int tail;
int size;
} ring_buffer_t;
int rb_available(ring_buffer_t *rb);
测试代码怎么写?看下面:
// test_rb_available.c
#include "unity.h"
#include "ring_buffer.h"
static ring_buffer_t rb;
static uint8_t buf[16];
void setUp(void) {
rb.buffer = buf;
rb.size = 16;
rb.head = 0;
rb.tail = 0;
}
void tearDown(void) {
// 清理工作,这里不需要
}
void test_available_empty(void) {
TEST_ASSERT_EQUAL_INT(16, rb_available(&rb));
}
void test_available_after_write(void) {
rb.tail = 5; // 模拟写了5个字节
TEST_ASSERT_EQUAL_INT(11, rb_available(&rb));
}
void test_available_full(void) {
rb.tail = 0;
rb.head = 1; // 头尾相邻表示满
TEST_ASSERT_EQUAL_INT(0, rb_available(&rb));
}
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_available_empty);
RUN_TEST(test_available_after_write);
RUN_TEST(test_available_full);
return UNITY_END();
}
这里有个细节:setUp 和 tearDown 是 Unity 的钩子函数。每个测试用例运行前都会调用 setUp,运行后调用 tearDown。这样能保证每个用例的初始状态是干净的。
测试套件:把用例组织起来
一个模块可能有几十个测试用例。全塞在一个 main 函数里?那太乱了。我们需要 测试套件 来分组管理。
在 Unity 里,测试套件其实就是按功能把用例分到不同的 .c 文件里。比如:
// test_rb_basic.c —— 基本功能测试
void test_create(void);
void test_destroy(void);
void test_reset(void);
// test_rb_read_write.c —— 读写测试
void test_write_one_byte(void);
void test_write_multiple_bytes(void);
void test_read_one_byte(void);
void test_read_overflow(void);
// test_rb_boundary.c —— 边界测试
void test_write_when_full(void);
void test_read_when_empty(void);
void test_wrap_around(void);
然后写一个主测试文件,统一调用:
// run_all_tests.c
#include "unity.h"
/* 声明外部测试函数 */
void test_create(void);
void test_destroy(void);
void test_write_one_byte(void);
// ... 其他声明
int main(void) {
UNITY_BEGIN();
/* 基本功能套件 */
RUN_TEST(test_create);
RUN_TEST(test_destroy);
/* 读写套件 */
RUN_TEST(test_write_one_byte);
RUN_TEST(test_write_multiple_bytes);
/* 边界套件 */
RUN_TEST(test_write_when_full);
RUN_TEST(test_read_when_empty);
return UNITY_END();
}
测试用例的命名规范
我踩过一个坑:测试用例名字起得太随意,跑起来根本不知道哪个挂了。后来我定了个规矩:
- test_模块名_功能_场景 —— 比如
test_rb_write_when_buffer_full - 不要用
test1、test2这种名字,三个月后你自己都看不懂 - 如果测试失败,名字要能直接告诉你是哪个模块、哪个功能、什么场景出了问题
测试覆盖率:别盲目追求100%
很多人觉得单元测试就要覆盖所有代码路径。其实不然。我在项目中见过有人为了凑覆盖率,写了大量「测了个寂寞」的用例。
我个人建议优先覆盖:
- 正常路径 —— 函数最常用的调用方式
- 边界值 —— 比如数组下标0和最大值
- 错误处理 —— 传入NULL指针、空缓冲区等
- 状态转换 —— 如果模块有状态机,每个状态转换都要测
用 CUnit 做更复杂的测试
如果你的开发环境是 Linux,或者项目跑在 PC 上,CUnit 也是个好选择。它比 Unity 功能更丰富,支持测试套件的嵌套、自动生成测试报告等。
一个 CUnit 的测试套件示例:
#include <CUnit/CUnit.h>
#include <CUnit/Basic.h>
/* 测试函数 */
void test_math_add(void) {
CU_ASSERT_EQUAL(add(2, 3), 5);
CU_ASSERT_EQUAL(add(-1, 1), 0);
}
void test_math_sub(void) {
CU_ASSERT_EQUAL(sub(5, 3), 2);
CU_ASSERT_EQUAL(sub(0, 5), -5);
}
int main(void) {
CU_pSuite pSuite = NULL;
/* 初始化 */
CU_initialize_registry();
/* 创建套件 */
pSuite = CU_add_suite("Suite_Math", NULL, NULL);
/* 添加测试用例 */
CU_add_test(pSuite, "test_math_add", test_math_add);
CU_add_test(pSuite, "test_math_sub", test_math_sub);
/* 运行 */
CU_basic_set_mode(CU_BRM_VERBOSE);
CU_basic_run_tests();
CU_cleanup_registry();
return 0;
}
CUnit 的套件可以嵌套,比如「数学套件」下面可以分「加法子套件」、「减法子套件」。这在测试大型项目时很有用。
测试驱动开发(TDD)在嵌入式中的实践
TDD 的理念是:先写测试,再写代码。听起来反直觉,但实际用起来很爽。
我个人的做法是:
- 先想好函数接口长什么样(参数、返回值)
- 写一个会失败的测试用例(红)
- 写最简代码让测试通过(绿)
- 重构代码,保持测试通过
举个例子,我要写一个 crc16 函数:
// 第一步:写测试
void test_crc16_calculation(void) {
uint8_t data[] = {0x01, 0x02, 0x03};
uint16_t result = crc16(data, 3);
TEST_ASSERT_EQUAL_HEX16(0xCAFE, result); // 预期值先随便写
}
// 第二步:编译,测试失败(因为 crc16 还没实现)
// 第三步:写最简实现
uint16_t crc16(uint8_t *data, int len) {
// 先返回固定值让测试通过
return 0xCAFE;
}
// 第四步:完善实现,确保测试仍然通过
你可能会觉得「先返回固定值」有点作弊。但这就是 TDD 的精髓:让测试先通过,再逐步完善。每一步都有测试兜底,你改代码的时候心里有底。
嵌入式测试的特殊问题
嵌入式环境有个头疼的问题:硬件依赖。比如你的函数要读写 GPIO 寄存器,单元测试在 PC 上跑不了。
我的解决方案是:抽象硬件层。把硬件操作封装成函数指针或者弱符号,测试时替换成模拟实现。
// hardware.h
typedef void (*gpio_write_t)(int pin, int value);
extern gpio_write_t gpio_write;
// 测试时替换
void mock_gpio_write(int pin, int value) {
// 记录调用,不操作真实硬件
last_pin = pin;
last_value = value;
}
void setUp(void) {
gpio_write = mock_gpio_write; // 替换成模拟函数
}
这样,你的业务逻辑代码和硬件代码就解耦了。测试时跑的是纯逻辑,不会烧坏板子。