14、单元测试框架:CUnit与Unity框架介绍、测试用例编写、测试套件组织
单元测试这件事,说实话,在嵌入式圈子里长期被忽视。我早年带团队时,经常听到一句话:“跑一跑不就知道了?”结果呢?产品交付前通宵改bug,改完一个又冒出一个。后来我强制团队引入单元测试,才真正把质量关前移了。
今天咱们就聊聊C语言里两个主流的单元测试框架:CUnit和Unity。它们各有各的脾气,但都能帮你把代码质量管起来。
14.1 为什么需要单元测试框架?
你可能会问:“我自己写个printf打印结果,不也能测试吗?”嗯,可以,但项目一复杂就崩了。我见过一个同事,用200行printf做测试,最后改个接口,打印信息全乱套。
单元测试框架能给你三样东西:
- 自动化执行:一键跑完所有测试,不用手动看输出
- 断言机制:自动判断对错,不用肉眼比对
- 测试套件:把相关用例组织起来,方便管理
核心观点:单元测试不是“测给别人看的”,而是“帮自己兜底的”。我每次重构代码前,先把测试跑一遍,心里才有底。
14.2 CUnit框架介绍
CUnit是老牌框架了,结构清晰,适合大型项目。它把测试组织成三级结构:Registry(注册表)→ Suite(套件)→ Test(用例)。
我个人习惯用CUnit做功能模块的集成测试,因为它支持套件间的依赖管理。
14.2.1 安装与基本结构
CUnit需要安装库,Linux下直接apt-get就行。它的核心API分三块:
- 初始化:CU_initialize_registry()
- 注册套件:CU_add_suite()
- 添加用例:CU_add_test()
#include <CUnit/CUnit.h>
#include <CUnit/Basic.h>
/* 测试函数 */
void test_add(void) {
CU_ASSERT(2 + 2 == 4);
CU_ASSERT_EQUAL(add(1, 2), 3);
}
void test_sub(void) {
CU_ASSERT(sub(5, 3) == 2);
}
int main() {
CU_initialize_registry();
CU_pSuite pSuite = CU_add_suite("算术测试套件", NULL, NULL);
CU_add_test(pSuite, "测试加法", test_add);
CU_add_test(pSuite, "测试减法", test_sub);
CU_basic_run_tests();
CU_cleanup_registry();
return 0;
}
小技巧:CU_add_suite的第二个参数是初始化函数,第三个是清理函数。我经常在初始化里malloc资源,在清理里free掉,这样每个套件独立,不会内存泄漏。
14.2.2 常用断言宏
| 宏 | 说明 |
|---|---|
| CU_ASSERT(expr) | 断言表达式为真 |
| CU_ASSERT_EQUAL(a, b) | 断言a == b |
| CU_ASSERT_STRING_EQUAL(s1, s2) | 断言字符串相等 |
| CU_ASSERT_PTR_NULL(ptr) | 断言指针为空 |
我曾经踩过一个坑:CU_ASSERT_EQUAL比较浮点数时,精度问题导致测试失败。后来我改用CU_ASSERT_DOUBLE_EQUAL,指定容忍误差,才搞定。
14.3 Unity框架介绍
Unity是嵌入式圈子的新宠,轻量、无依赖,一个.c文件就能跑。我特别喜欢它用在资源受限的MCU上,比如STM32、ESP32。
14.3.1 极简集成
Unity只有三个文件:unity.c、unity.h、unity_internals.h。你把它拷到项目里,直接编译就行。
#include "unity.h"
void setUp(void) {} // 每个用例前执行
void tearDown(void) {} // 每个用例后执行
void test_add_should_pass(void) {
TEST_ASSERT_EQUAL_INT(4, add(2, 2));
}
void test_sub_should_pass(void) {
TEST_ASSERT_EQUAL_INT(2, sub(5, 3));
}
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_add_should_pass);
RUN_TEST(test_sub_should_pass);
return UNITY_END();
}
对比一下:CUnit像瑞士军刀,功能全但重;Unity像手术刀,轻巧锋利。我建议:PC端开发用CUnit,嵌入式裸机用Unity。
14.3.2 Unity的断言家族
Unity的断言命名很直观:
- TEST_ASSERT_EQUAL_INT(a, b)
- TEST_ASSERT_EQUAL_HEX(a, b)
- TEST_ASSERT_EQUAL_STRING(s1, s2)
- TEST_ASSERT_BIT_HIGH(bit, word)
嗯,这里要注意:Unity没有浮点数断言,需要自己写辅助函数。我一般用TEST_ASSERT_FLOAT_WITHIN(delta, expected, actual)。
14.4 测试用例编写实战
光讲理论没意思,咱们来点实际的。假设你写了一个环形缓冲区模块,怎么测?
14.4.1 边界值测试
我习惯先测边界:空缓冲区、满缓冲区、读写指针重叠。
void test_ringbuf_empty(void) {
ringbuf_t *rb = ringbuf_create(4);
TEST_ASSERT_TRUE(ringbuf_is_empty(rb));
ringbuf_destroy(rb);
}
void test_ringbuf_full(void) {
ringbuf_t *rb = ringbuf_create(4);
ringbuf_write(rb, 1);
ringbuf_write(rb, 2);
ringbuf_write(rb, 3);
ringbuf_write(rb, 4);
TEST_ASSERT_TRUE(ringbuf_is_full(rb));
ringbuf_destroy(rb);
}
避坑指南:我曾经在测试环形缓冲区时,忘了考虑“写满后覆盖”的场景。结果产品上线后,数据被静默覆盖,排查了两天才找到原因。所以测试一定要覆盖“写满后再写”的行为。
14.4.2 异常路径测试
别只测正常流程。我见过太多人只测“happy path”,结果异常一来就崩。
void test_ringbuf_null_pointer(void) {
TEST_ASSERT_EQUAL_INT(-1, ringbuf_write(NULL, 10));
TEST_ASSERT_EQUAL_INT(-1, ringbuf_read(NULL));
}
14.5 测试套件组织
项目大了,用例多了,怎么组织?我一般按模块分套件。
14.5.1 CUnit的套件组织
CU_pSuite pMathSuite = CU_add_suite("数学模块", init_math, clean_math);
CU_add_test(pMathSuite, "加法测试", test_add);
CU_add_test(pMathSuite, "减法测试", test_sub);
CU_pSuite pRingSuite = CU_add_suite("环形缓冲区", init_ring, clean_ring);
CU_add_test(pRingSuite, "空缓冲区测试", test_empty);
CU_add_test(pRingSuite, "满缓冲区测试", test_full);
14.5.2 Unity的套件组织
Unity没有显式的套件概念,但你可以用分组函数模拟:
void run_math_tests(void) {
RUN_TEST(test_add);
RUN_TEST(test_sub);
}
void run_ringbuf_tests(void) {
RUN_TEST(test_empty);
RUN_TEST(test_full);
}
int main(void) {
UNITY_BEGIN();
run_math_tests();
run_ringbuf_tests();
return UNITY_END();
}
我的习惯:每个模块建一个测试文件,比如test_math.c、test_ringbuf.c。然后在主测试文件里统一调用。这样新人接手时,一眼就能看出测试覆盖了哪些模块。
14.6 知识体系总览
下面这张图,是我梳理的单元测试框架核心脉络。你看一眼,心里就有谱了。
14.7 写在最后
单元测试这件事,说白了就是“花小钱办大事”。你写测试用例花的时间,会在后期调试时十倍百倍地省回来。我这些年带项目,凡是坚持写单元测试的模块,线上bug率至少降了60%。
别犹豫了,从今天开始,给你的C代码加上单元测试吧。哪怕先从一个小模块开始,也比没有强。
一句话总结:CUnit适合大项目,Unity适合嵌入式。选一个,用起来,你会感谢自己的。
公众号:蓝海资料掘金营,微信deep3321