第26章:单元测试模块——C语言单元测试框架的使用
说实话,很多C语言开发者对单元测试的态度是「知道很重要,但就是懒得写」。我早年做嵌入式项目时也是这样,总觉得跑一下硬件就能验证,何必多此一举?直到有一次,一个底层驱动的小bug在集成测试阶段才被发现,定位花了整整三天……嗯,从那以后,我再也不敢轻视单元测试了。
这一章,我们就来聊聊C语言中怎么搞单元测试。我会以Unity和CuTest这两个轻量级框架为例,带你走一遍测试用例的编写流程,再聊聊测试覆盖率这个绕不开的话题。
26.1 为什么C语言需要单元测试框架?
你可能会问:「我写个main函数,调一下函数,打印结果看看,不也是测试吗?」
没错,这确实是一种测试方式。但问题在于:
- 手动测试不可重复,改一次代码就得重新跑一遍
- 没有统一的断言机制,判断结果全靠肉眼
- 无法自动化集成到构建流程中
- 大型项目里,手动测试根本覆盖不过来
单元测试框架解决的就是这些问题。它提供了一套标准化的「断言」函数,让你能用代码来验证代码。跑一次测试,所有用例自动执行,结果一目了然。
26.2 两个轻量级框架:Unity 与 CuTest
嵌入式C语言领域,最常用的轻量级测试框架就是Unity和CuTest。它们都不需要复杂的安装,一个.c和一个.h文件就能跑起来。
| 特性 | Unity | CuTest |
|---|---|---|
| 文件数量 | 3个核心文件 | 2个核心文件 |
| 断言类型 | 丰富(int/string/ptr/hex等) | 基础(int/string/ptr) |
| 测试分组 | 支持TEST_GROUP/TEST | 支持TestSuite |
| 输出格式 | 可定制(默认文本) | 文本格式 |
| 适合场景 | 中大型项目,需要丰富断言 | 小型项目,快速上手 |
我个人习惯用Unity,因为它的断言更丰富,比如TEST_ASSERT_EQUAL_HEX在调试寄存器值时特别方便。但如果你只是想快速验证几个函数,CuTest也完全够用。
26.3 用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 storage[16];
void setUp(void) {
rb.buffer = storage;
rb.head = 0;
rb.tail = 0;
rb.size = 16;
}
void tearDown(void) {
// 清理工作,这里不需要
}
void test_rb_available_empty(void) {
TEST_ASSERT_EQUAL_INT(15, rb_available(&rb));
}
void test_rb_available_full(void) {
// 模拟缓冲区写满
rb.head = 0;
rb.tail = 15;
TEST_ASSERT_EQUAL_INT(0, rb_available(&rb));
}
void test_rb_available_partial(void) {
rb.head = 0;
rb.tail = 5;
TEST_ASSERT_EQUAL_INT(10, rb_available(&rb));
}
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_rb_available_empty);
RUN_TEST(test_rb_available_full);
RUN_TEST(test_rb_available_partial);
return UNITY_END();
}
这里有几个关键点:
- setUp/tearDown:每个测试用例执行前后自动调用,保证测试环境独立
- TEST_ASSERT_EQUAL_INT:断言两个int值相等,如果不相等框架会报错并继续执行下一个用例
- RUN_TEST:注册并执行一个测试用例
26.4 CuTest的使用方式
CuTest的用法和Unity类似,但更简洁一些:
#include "CuTest.h"
void test_add(CuTest *tc) {
int result = add(2, 3);
CuAssertIntEquals(tc, 5, result);
}
void test_sub(CuTest *tc) {
int result = sub(5, 3);
CuAssertIntEquals(tc, 2, result);
}
CuSuite* math_suite(void) {
CuSuite* suite = CuSuiteNew();
SUITE_ADD_TEST(suite, test_add);
SUITE_ADD_TEST(suite, test_sub);
return suite;
}
int main(void) {
CuSuite* suite = math_suite();
CuSuiteRun(suite);
CuSuiteSummary(suite);
CuSuiteDetails(suite);
CuSuiteDelete(suite);
return 0;
}
CuTest的断言函数第一个参数都是CuTest指针,这是它的约定。我个人觉得CuTest的API更「C语言原生」一些,没有那么多宏,适合喜欢简洁风格的开发者。
26.5 测试用例的设计原则
写测试用例不是「随便写几个值试试」。我总结了几个原则:
- 一个用例只测一个场景:比如「空缓冲区」「满缓冲区」「半满缓冲区」各写一个用例,不要混在一起
- 边界值优先:数组下标0、最大值、空指针、长度为1的字符串……这些边界最容易出bug
- 异常路径也要测:比如传入NULL指针、除数为0、缓冲区溢出等
- 测试结果要可预测:不要依赖随机数、当前时间等不确定因素
26.6 测试覆盖率的概念
测试覆盖率,说白了就是「你的测试用例跑了多少代码」。常见的覆盖率指标有:
| 覆盖率类型 | 含义 | 举例 |
|---|---|---|
| 语句覆盖率 | 每行代码是否被执行过 | if语句的两条分支都跑到了吗? |
| 分支覆盖率 | 每个判断条件的true/false是否都覆盖 | if(a > 0 && b < 10) 的四种组合 |
| 条件覆盖率 | 每个布尔子表达式的true/false | a > 0 和 b < 10 各自都测到过true和false |
| 函数覆盖率 | 每个函数是否被调用过 | 所有公开函数都至少被一个用例调用 |
在嵌入式C项目中,我一般用gcov来测量覆盖率。编译时加上-fprofile-arcs -ftest-coverage,运行测试后就会生成.gcda文件,再用gcov命令就能看到每行代码的执行次数。
# 编译时开启覆盖率
gcc -fprofile-arcs -ftest-coverage test_ring_buffer.c ring_buffer.c -o test_ring_buffer
# 运行测试
./test_ring_buffer
# 查看覆盖率
gcov ring_buffer.c
输出结果会显示每行代码被执行的次数,以及未覆盖的行号。比如:
-: 0:Source:ring_buffer.c
-: 1:ring_buffer.c
1: 2:int rb_available(ring_buffer_t *rb) {
1: 3: if (rb == NULL) return -1;
#####: 4: return (rb->tail - rb->head - 1 + rb->size) % rb->size;
-: 5:}
这里的#####表示第4行从未被执行过——说明我们的测试用例没有覆盖到正常路径。嗯,这就是覆盖率工具的价值:告诉你「你以为测了,其实没测到」的地方。
26.7 知识体系总览
下面这张图总结了本章的核心逻辑:从测试框架的选择,到用例编写,再到覆盖率度量,形成一条完整的单元测试链路。
26.8 写在最后
单元测试这件事,刚开始做的时候会觉得「浪费时间」。但一旦你经历过几次「改了一行代码,跑一下测试,发现三个地方挂了」的体验,你就会爱上它。
我个人建议:从今天开始,给你手头的每个模块至少写一个测试用例。不用多,一个就行。先跑通框架,再慢慢补充。你会发现,代码质量在不知不觉中就上去了。