33、单元测试:CUnit/Unity框架使用,测试用例编写,覆盖率

说实话,很多C语言开发者对单元测试的态度是「知道很重要,但就是懒得写」。我早年也这样,觉得嵌入式代码跑在硬件上,测不测无所谓。直到有一次,一个定时器中断里的边界条件没测到,产品在现场跑了三天就死机了……从那以后,我再也不敢跳过单元测试。

单元测试说白了,就是把你的函数从整个系统里「拆」出来,单独验证它对不对。你想想看,一个函数有几十条分支,靠肉眼review能看出多少?不如让测试代码替你跑一遍。

为什么C语言需要单元测试框架?

你可能觉得,写个main函数调一下不就行了?嗯,对于一两个函数确实可以。但项目大了以后,你会发现:

  • 每次都要手动改main函数,麻烦
  • 测试结果没有统一格式,没法自动化
  • 没人知道哪些分支测过了,哪些没测

框架就是来解决这些问题的。它帮你做三件事:组织测试用例、断言结果、统计覆盖率。我个人习惯用Unity,轻量、无依赖,非常适合嵌入式环境。

Unity框架快速上手

Unity是啥?一个纯C的测试框架,就一个.c和一个.h文件。你把它拷到项目里,写几个测试函数,然后跑起来就行。

先看一个最简单的例子。假设你要测试一个计算校验和的函数:

// calc.h
uint8_t calc_checksum(const uint8_t* data, uint32_t len);

// calc.c
uint8_t calc_checksum(const uint8_t* data, uint32_t len) {
    uint8_t sum = 0;
    for (uint32_t i = 0; i < len; i++) {
        sum += data[i];
    }
    return sum;
}

现在写测试:

// test_calc.c
#include "unity.h"
#include "calc.h"

void setUp(void) {}    // 每个测试前执行
void tearDown(void) {} // 每个测试后执行

void test_checksum_empty_data(void) {
    uint8_t data[] = {};
    TEST_ASSERT_EQUAL_UINT8(0, calc_checksum(data, 0));
}

void test_checksum_normal(void) {
    uint8_t data[] = {0x01, 0x02, 0x03};
    TEST_ASSERT_EQUAL_UINT8(0x06, calc_checksum(data, 3));
}

void test_checksum_overflow(void) {
    uint8_t data[] = {0xFF, 0x01};
    // 0xFF + 0x01 = 0x100,截断为0x00
    TEST_ASSERT_EQUAL_UINT8(0x00, calc_checksum(data, 2));
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_checksum_empty_data);
    RUN_TEST(test_checksum_normal);
    RUN_TEST(test_checksum_overflow);
    return UNITY_END();
}

编译运行,你会看到类似这样的输出:

test_calc.c:12:test_checksum_empty_data:PASS
test_calc.c:17:test_checksum_normal:PASS
test_calc.c:22:test_checksum_overflow:PASS
-----------------------
3 Tests 0 Failures 0 Ignored
OK
我的习惯:每个测试函数只测一个「场景」。比如空数据、正常数据、溢出边界,分开写。这样哪个挂了,一眼就知道问题在哪。

测试用例编写的核心思路

写测试用例不是瞎写。我总结了一个简单的原则:输入边界 + 正常路径 + 异常路径

举个例子,你要测一个限幅函数:

int16_t clamp(int16_t value, int16_t min, int16_t max);

测试用例应该覆盖:

  • 正常值:value在[min, max]内,返回value本身
  • 低于下限:value < min,返回min
  • 高于上限:value > max,返回max
  • 边界相等:value == min 或 value == max
  • min > max:参数错误,函数怎么处理?

你看,一个简单的限幅函数,至少5个测试用例。我在项目中遇到过有人只写了前三个,结果min==max时出了bug,找了半天。

我曾经踩过的坑:测试用例里用了全局变量,结果多个测试之间互相影响。比如test_A修改了全局状态,test_B依赖这个状态,但test_B先跑……嗯,后来我强制每个测试前都用setUp()重置所有全局变量。

覆盖率:你到底测了多少?

覆盖率是个好东西,但别迷信。我见过有人追求100%行覆盖率,结果所有分支都跑了,但组合条件没测到。覆盖率工具一般支持以下几种:

类型 含义 我的建议
行覆盖率 每行代码是否被执行 至少90%以上
分支覆盖率 每个if/else是否都走过 100%最好
条件覆盖率 每个布尔子表达式是否都取过true/false 关键模块必做
MC/DC 每个条件独立影响结果 安全关键系统要求

在嵌入式C项目中,我一般用gcov配合lcov生成覆盖率报告。命令很简单:

gcc -fprofile-arcs -ftest-coverage test_calc.c calc.c -o test_calc
./test_calc
gcov calc.c
lcov -c -d . -o coverage.info
genhtml coverage.info -o report

然后打开report/index.html,就能看到哪些行是红色(没覆盖到),哪些是绿色(覆盖到了)。

一个实用的经验:别一开始就追求100%覆盖率。先定个目标,比如80%。然后每次提交代码时,覆盖率不能比上次低。这样逐步提升,团队也容易接受。

嵌入式环境下的特殊问题

嵌入式代码经常依赖硬件寄存器、中断、外设。这些在PC上没法直接跑。怎么办?我的做法是:

  • 硬件抽象层(HAL):把硬件操作封装成函数,测试时用模拟实现替换
  • 条件编译:用#ifdef UNIT_TEST隔离硬件相关代码
  • 模拟器:有些MCU厂商提供指令集模拟器,可以在上面跑测试

举个例子,你有一个读取ADC的函数:

// adc.h
uint16_t adc_read(uint8_t channel);

// adc.c
uint16_t adc_read(uint8_t channel) {
    // 操作硬件寄存器
    ADC->CHSEL = channel;
    ADC->START = 1;
    while (!(ADC->FLAG & ADC_FLAG_EOC));
    return ADC->DATA;
}

测试时,你可以这样模拟:

// adc_mock.c
static uint16_t mock_values[8] = {0};
void adc_mock_set(uint8_t channel, uint16_t value) {
    mock_values[channel] = value;
}

uint16_t adc_read(uint8_t channel) {
    return mock_values[channel];
}

然后在测试代码里,链接adc_mock.c而不是adc.c。这样就能在PC上测试所有逻辑了。

一个小技巧:用链接时的符号替换,比用条件编译更干净。你不需要改生产代码,只需要在Makefile里换一个.o文件就行。

知识体系总览

下面这张图概括了本章的核心内容,从框架选择到覆盖率分析,一条线串起来:

单元测试知识体系 测试框架选择 Unity(轻量嵌入式) CUnit(功能丰富) CMock(自动Mock) 测试用例编写策略 正常路径 + 边界值 异常输入 + 错误处理 Mock硬件依赖 覆盖率分析(gcov/lcov)

从框架选择开始,到测试用例编写,最后用覆盖率工具验证。每一步都有对应的工具和方法。我个人建议新手从Unity开始,它简单到你看一眼就会用。

写在最后

单元测试这件事,刚开始会觉得「多此一举」。但当你维护一个超过10万行代码的项目时,你就会感谢当初写了测试的自己。我见过太多bug是因为改了一处,另一处崩了,而测试能第一时间告诉你。

别追求完美,先跑起来。哪怕只给核心模块写了测试,也比没有强。记住:没有测试的代码,就是遗留代码


专注资料整理