第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:注册并执行一个测试用例
我的经验:setUp里一定要把被测对象恢复到「干净」状态。我曾经因为忘记重置全局变量,导致一个测试用例的结果影响了后面三个用例,排查了半小时才发现。从那以后,我每个测试用例的setUp都写得特别仔细。

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 测试用例的设计原则

写测试用例不是「随便写几个值试试」。我总结了几个原则:

  1. 一个用例只测一个场景:比如「空缓冲区」「满缓冲区」「半满缓冲区」各写一个用例,不要混在一起
  2. 边界值优先:数组下标0、最大值、空指针、长度为1的字符串……这些边界最容易出bug
  3. 异常路径也要测:比如传入NULL指针、除数为0、缓冲区溢出等
  4. 测试结果要可预测:不要依赖随机数、当前时间等不确定因素
避坑指南:我曾经写过一个测试用例,里面用到了rand()来生成测试数据。结果每次跑测试结果都不一样,有时候通过有时候失败。后来才发现,rand()的种子没固定,导致测试不可复现。记住:单元测试必须是「确定性」的。

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行从未被执行过——说明我们的测试用例没有覆盖到正常路径。嗯,这就是覆盖率工具的价值:告诉你「你以为测了,其实没测到」的地方。

我的建议:不要追求100%覆盖率。有些代码(比如错误处理分支、硬件初始化失败路径)很难在单元测试中触发。我个人习惯把目标定在80%~90%,剩下的靠集成测试和硬件测试来补。

26.7 知识体系总览

下面这张图总结了本章的核心逻辑:从测试框架的选择,到用例编写,再到覆盖率度量,形成一条完整的单元测试链路。

C语言单元测试知识体系 测试框架选择 Unity CuTest 测试用例编写 setUp/tearDown 断言函数 边界值测试 异常路径 测试覆盖率度量(gcov) 语句覆盖率 分支覆盖率 条件覆盖率 函数覆盖率

26.8 写在最后

单元测试这件事,刚开始做的时候会觉得「浪费时间」。但一旦你经历过几次「改了一行代码,跑一下测试,发现三个地方挂了」的体验,你就会爱上它。

我个人建议:从今天开始,给你手头的每个模块至少写一个测试用例。不用多,一个就行。先跑通框架,再慢慢补充。你会发现,代码质量在不知不觉中就上去了。


公众号:蓝海资料掘金营,微信deep3321