单元测试:CUnit框架、测试用例编写、mock对象、覆盖率gcov

说实话,很多C语言开发者对单元测试的态度是「知道很重要,但就是懒得写」。我早年也这样,觉得嵌入式或者命令行工具嘛,跑一遍看结果不就行了?直到有一次,我改了一个字符串处理函数,自以为没问题,结果上线后把配置文件的解析全搞崩了。嗯,从那以后,我再也不敢不写单元测试了。

今天我们就来聊聊C语言单元测试的实战套路。我会用CUnit这个轻量级框架,带你走一遍测试用例怎么写、mock对象怎么用、覆盖率怎么用gcov来度量。

1. CUnit框架:轻量、够用、不折腾

CUnit是我个人比较喜欢的一个测试框架。它不像Check或者Unity那样需要额外配置,直接包含头文件、链接库就能用。说白了,它就是一个C语言写的测试管理工具,帮你组织测试用例、运行测试、输出结果。

先看一个最简单的例子:

#include <CUnit/CUnit.h>
#include <CUnit/Basic.h>

int add(int a, int b) {
    return a + b;
}

void test_add(void) {
    CU_ASSERT(add(2, 3) == 5);
    CU_ASSERT(add(-1, 1) == 0);
    CU_ASSERT(add(0, 0) == 0);
}

int main() {
    CU_initialize_registry();
    CU_pSuite suite = CU_add_suite("math_suite", NULL, NULL);
    CU_add_test(suite, "test_add", test_add);
    CU_basic_run_tests();
    CU_cleanup_registry();
    return 0;
}

你看,核心就三步:初始化注册表、添加测试套件和测试用例、运行。我习惯把每个模块的测试单独写一个.c文件,这样维护起来清爽。

小技巧:CU_basic_set_mode(CU_BRM_VERBOSE) 可以输出更详细的测试信息,调试时特别有用。

2. 测试用例编写:别只测「快乐路径」

很多新手写测试,只测正常情况。比如测一个除法函数,只测 6/3=2,不测除数为0的情况。你想想看,真正出bug的往往就是这些边界条件。

我总结了一个测试用例的「三覆盖」原则:

  • 正常路径: 输入合法,输出符合预期
  • 边界条件: 最大值、最小值、空指针、0值
  • 异常输入: 非法参数、越界、格式错误

举个例子,假设我们要测试一个解析命令行参数的函数:

void test_parse_args_normal(void) {
    const char *argv[] = {"tool", "-f", "config.ini", NULL};
    int argc = 3;
    Config cfg;
    int ret = parse_args(argc, argv, &cfg);
    CU_ASSERT(ret == 0);
    CU_ASSERT_STRING_EQUAL(cfg.filename, "config.ini");
}

void test_parse_args_null(void) {
    int ret = parse_args(0, NULL, NULL);
    CU_ASSERT(ret == -1);  // 期望返回错误码
}

void test_parse_args_missing_value(void) {
    const char *argv[] = {"tool", "-f", NULL};
    int argc = 2;
    Config cfg;
    int ret = parse_args(argc, argv, &cfg);
    CU_ASSERT(ret == -2);  // 缺少参数值
}

我曾经在一个项目中漏测了「参数重复」的情况,结果用户写了两次 -o 选项,程序直接崩溃。从那以后,我每个函数的测试用例至少写5个以上。

3. mock对象:隔离外部依赖

单元测试的核心原则是「只测当前函数」。如果你的函数调用了数据库、网络、文件系统,那测试就变得不可控了。这时候就需要mock——说白了,就是用一个假的实现替换掉真实的外部依赖。

C语言没有原生的mock支持,但我们可以用函数指针或者编译时替换来实现。我常用的方式有两种:

方式 适用场景 缺点
函数指针 模块内部调用可配置 需要修改原代码结构
链接时替换 测试时链接mock库 需要单独编译mock对象

我比较推荐链接时替换,因为它对原代码零侵入。举个例子,你的函数调用了 read_sensor(),真实环境需要硬件,测试时我们可以写一个mock:

// mock_sensor.c
int read_sensor(void) {
    // 返回固定值,模拟传感器
    return 25;  // 模拟25度
}

// 测试代码
void test_process_temperature(void) {
    int result = process_temperature();
    CU_ASSERT(result == 25 * 2);  // 假设处理函数是乘以2
}

编译时,把mock_sensor.c链接进去,真实sensor.c不参与编译。这样测试就完全可控了。

注意: mock对象要足够简单,不要在里面写复杂逻辑。否则你测的其实是mock本身,而不是被测函数。

4. 覆盖率gcov:看看你的测试漏了哪

写了一大堆测试,怎么知道测全了没有?gcov就是干这个的。它是gcc自带的覆盖率分析工具,能告诉你每一行代码被执行了多少次。

使用步骤很简单:

  1. 编译时加 -fprofile-arcs -ftest-coverage 选项
  2. 运行测试程序,生成 .gcda 文件
  3. gcov 命令分析,生成 .gcov 报告

举个例子:

gcc -fprofile-arcs -ftest-coverage -o test_program test.c mylib.c
./test_program
gcov mylib.c
cat mylib.c.gcov

输出文件里,每行前面会有一个数字,表示该行被执行了多少次。如果显示 #####,说明这一行从来没被执行过——这就是你测试的盲区。

我个人习惯把覆盖率目标定在80%以上。不是说要100%,因为有些错误处理代码很难触发。但核心逻辑必须全覆盖。

核心观点: 覆盖率不是目的,而是手段。它告诉你「哪些代码你还没测到」,而不是「你的测试已经够了」。

5. 知识体系总览

下面这张图是我整理的单元测试知识结构,你可以对照着检查自己掌握了哪些:

C语言单元测试 CUnit框架 注册测试套件 添加测试用例 运行与报告 测试用例编写 正常路径 边界条件 异常输入 Mock对象 函数指针 链接时替换 隔离外部依赖 gcov覆盖率 编译选项 运行生成.gcda 分析.gcov报告

6. 实战建议:从一个小模块开始

如果你之前没写过单元测试,我建议不要一上来就搞大工程。选一个你最近写的、功能独立的模块,比如字符串处理、配置解析、数学计算,先写10个测试用例跑起来。

等你习惯了「先写测试再写代码」的节奏,你会发现代码质量明显提升。我现在的习惯是:每写一个函数,先想好它的输入输出边界,然后写测试,最后才实现。这叫测试驱动开发(TDD),说白了就是让测试牵着你的代码走。

避坑指南: 我曾经在测试里直接用了全局变量,结果多个测试用例互相影响,查了一下午才发现。记住:每个测试用例应该是独立的,不要依赖执行顺序。

好了,单元测试这块就聊到这儿。记住一句话:测试不是浪费时间,而是在帮你节省未来几倍的调试时间。动手试试吧。


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