单元测试入门:CUnit/Unity框架、测试用例编写、测试套件组织

说实话,我刚入行那会儿,对单元测试是有点抵触的。总觉得「代码能跑就行,写测试不是浪费时间吗?」直到有一次,我花了两天改了一个模块,结果把另一个模块搞崩了,定位问题又花了一天。嗯,从那以后,我再也不敢不写测试了。

单元测试,说白了就是给函数写「体检报告」。你写了一个函数,它到底对不对?边界情况处理了吗?异常输入会崩溃吗?这些不能靠猜,得靠测试用例来验证。

为什么嵌入式开发更需要单元测试?

嵌入式软件有个特点:出问题很难复现。你在开发板上跑得好好的,一上产线就死机。或者客户用了三个月,突然某天系统重启了。这种问题,靠肉眼调试基本没戏。

我个人的习惯是:每个模块写完,先跑单元测试,再集成到主工程。这样能筛掉至少80%的低级错误。剩下的20%,往往是时序或硬件相关的问题,那属于集成测试的范畴。

两个主流框架:CUnit 和 Unity

C语言做单元测试,不像Java有JUnit那么成熟。但好在有两个框架用的人最多:CUnitUnity

特性 CUnit Unity
安装方式 需要编译安装库 纯头文件,直接拷贝
依赖 需要标准C库 无依赖,适合裸机
输出格式 文本/XML 文本
适合场景 Linux/桌面开发 嵌入式/资源受限环境
学习曲线 中等

我个人更推荐 Unity 做嵌入式项目。为什么?因为它就一个 .c 和一个 .h 文件,拷贝到工程里就能用。我在一个STM32项目里用过,编译出来不到2KB,完全不占资源。

用 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 buf[16];

void setUp(void) {
    rb.buffer = buf;
    rb.size = 16;
    rb.head = 0;
    rb.tail = 0;
}

void tearDown(void) {
    // 清理工作,这里不需要
}

void test_available_empty(void) {
    TEST_ASSERT_EQUAL_INT(16, rb_available(&rb));
}

void test_available_after_write(void) {
    rb.tail = 5;  // 模拟写了5个字节
    TEST_ASSERT_EQUAL_INT(11, rb_available(&rb));
}

void test_available_full(void) {
    rb.tail = 0;
    rb.head = 1;  // 头尾相邻表示满
    TEST_ASSERT_EQUAL_INT(0, rb_available(&rb));
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_available_empty);
    RUN_TEST(test_available_after_write);
    RUN_TEST(test_available_full);
    return UNITY_END();
}

这里有个细节:setUptearDown 是 Unity 的钩子函数。每个测试用例运行前都会调用 setUp,运行后调用 tearDown。这样能保证每个用例的初始状态是干净的。

小技巧: 我习惯在 setUp 里做「重置全局状态」的操作。比如清空缓冲区、复位标志位。这样即使某个测试用例写崩了,也不会影响后面的用例。

测试套件:把用例组织起来

一个模块可能有几十个测试用例。全塞在一个 main 函数里?那太乱了。我们需要 测试套件 来分组管理。

在 Unity 里,测试套件其实就是按功能把用例分到不同的 .c 文件里。比如:

// test_rb_basic.c —— 基本功能测试
void test_create(void);
void test_destroy(void);
void test_reset(void);

// test_rb_read_write.c —— 读写测试
void test_write_one_byte(void);
void test_write_multiple_bytes(void);
void test_read_one_byte(void);
void test_read_overflow(void);

// test_rb_boundary.c —— 边界测试
void test_write_when_full(void);
void test_read_when_empty(void);
void test_wrap_around(void);

然后写一个主测试文件,统一调用:

// run_all_tests.c
#include "unity.h"

/* 声明外部测试函数 */
void test_create(void);
void test_destroy(void);
void test_write_one_byte(void);
// ... 其他声明

int main(void) {
    UNITY_BEGIN();

    /* 基本功能套件 */
    RUN_TEST(test_create);
    RUN_TEST(test_destroy);

    /* 读写套件 */
    RUN_TEST(test_write_one_byte);
    RUN_TEST(test_write_multiple_bytes);

    /* 边界套件 */
    RUN_TEST(test_write_when_full);
    RUN_TEST(test_read_when_empty);

    return UNITY_END();
}
核心原则: 每个测试用例只测一件事。如果一个用例里写了三个断言,那它可能太复杂了。拆开,让每个用例的名字就能说明它在测什么。

测试用例的命名规范

我踩过一个坑:测试用例名字起得太随意,跑起来根本不知道哪个挂了。后来我定了个规矩:

  • test_模块名_功能_场景 —— 比如 test_rb_write_when_buffer_full
  • 不要用 test1test2 这种名字,三个月后你自己都看不懂
  • 如果测试失败,名字要能直接告诉你是哪个模块、哪个功能、什么场景出了问题

测试覆盖率:别盲目追求100%

很多人觉得单元测试就要覆盖所有代码路径。其实不然。我在项目中见过有人为了凑覆盖率,写了大量「测了个寂寞」的用例。

我个人建议优先覆盖:

  1. 正常路径 —— 函数最常用的调用方式
  2. 边界值 —— 比如数组下标0和最大值
  3. 错误处理 —— 传入NULL指针、空缓冲区等
  4. 状态转换 —— 如果模块有状态机,每个状态转换都要测
注意: 不要为了覆盖率而写「假测试」。比如一个函数里有个 if-else,你只测了 if 分支,else 分支没测到。那这个 else 分支就是定时炸弹。我曾经在一个项目里漏测了 else 分支,结果上线后那个分支被触发了,直接导致系统死锁。

用 CUnit 做更复杂的测试

如果你的开发环境是 Linux,或者项目跑在 PC 上,CUnit 也是个好选择。它比 Unity 功能更丰富,支持测试套件的嵌套、自动生成测试报告等。

一个 CUnit 的测试套件示例:

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

/* 测试函数 */
void test_math_add(void) {
    CU_ASSERT_EQUAL(add(2, 3), 5);
    CU_ASSERT_EQUAL(add(-1, 1), 0);
}

void test_math_sub(void) {
    CU_ASSERT_EQUAL(sub(5, 3), 2);
    CU_ASSERT_EQUAL(sub(0, 5), -5);
}

int main(void) {
    CU_pSuite pSuite = NULL;

    /* 初始化 */
    CU_initialize_registry();

    /* 创建套件 */
    pSuite = CU_add_suite("Suite_Math", NULL, NULL);

    /* 添加测试用例 */
    CU_add_test(pSuite, "test_math_add", test_math_add);
    CU_add_test(pSuite, "test_math_sub", test_math_sub);

    /* 运行 */
    CU_basic_set_mode(CU_BRM_VERBOSE);
    CU_basic_run_tests();
    CU_cleanup_registry();

    return 0;
}

CUnit 的套件可以嵌套,比如「数学套件」下面可以分「加法子套件」、「减法子套件」。这在测试大型项目时很有用。

测试驱动开发(TDD)在嵌入式中的实践

TDD 的理念是:先写测试,再写代码。听起来反直觉,但实际用起来很爽。

我个人的做法是:

  1. 先想好函数接口长什么样(参数、返回值)
  2. 写一个会失败的测试用例(红)
  3. 写最简代码让测试通过(绿)
  4. 重构代码,保持测试通过

举个例子,我要写一个 crc16 函数:

// 第一步:写测试
void test_crc16_calculation(void) {
    uint8_t data[] = {0x01, 0x02, 0x03};
    uint16_t result = crc16(data, 3);
    TEST_ASSERT_EQUAL_HEX16(0xCAFE, result);  // 预期值先随便写
}

// 第二步:编译,测试失败(因为 crc16 还没实现)

// 第三步:写最简实现
uint16_t crc16(uint8_t *data, int len) {
    // 先返回固定值让测试通过
    return 0xCAFE;
}

// 第四步:完善实现,确保测试仍然通过

你可能会觉得「先返回固定值」有点作弊。但这就是 TDD 的精髓:让测试先通过,再逐步完善。每一步都有测试兜底,你改代码的时候心里有底。

嵌入式测试的特殊问题

嵌入式环境有个头疼的问题:硬件依赖。比如你的函数要读写 GPIO 寄存器,单元测试在 PC 上跑不了。

我的解决方案是:抽象硬件层。把硬件操作封装成函数指针或者弱符号,测试时替换成模拟实现。

// hardware.h
typedef void (*gpio_write_t)(int pin, int value);
extern gpio_write_t gpio_write;

// 测试时替换
void mock_gpio_write(int pin, int value) {
    // 记录调用,不操作真实硬件
    last_pin = pin;
    last_value = value;
}

void setUp(void) {
    gpio_write = mock_gpio_write;  // 替换成模拟函数
}

这样,你的业务逻辑代码和硬件代码就解耦了。测试时跑的是纯逻辑,不会烧坏板子。

总结一下: 单元测试不是浪费时间,是在帮你省钱。一个 bug 在开发阶段发现,改起来可能只要 10 分钟。等到了产线或者客户手里,那就是几万块的损失。我这些年吃过的亏,总结下来就一句话——测试写得好,下班走得早
单元测试知识体系 单元测试框架 CUnit 框架 特性 • 需要编译安装库 • 支持 XML/文本输出 Unity 框架 特性 • 纯头文件,零依赖 • 适合嵌入式/裸机 测试用例编写 命名规范 test_模块_功能_场景 断言方法 TEST_ASSERT_EQUAL setUp/tearDown 每个用例前后自动调用 测试套件组织 按功能分组 基本功能 / 读写 / 边界 统一入口 run_all_tests.c 集中调用
我的建议: 刚开始接触单元测试,别想着一步到位。先挑一个你最常写的模块,用 Unity 写 3-5 个测试用例。跑通了,你就能体会到「测试过的代码,改起来就是踏实」的感觉。慢慢来,测试习惯是养出来的,不是逼出来的。
公众号:蓝海资料掘金营,微信deep3321