单元测试:如何使用 CUnit 进行单元测试?

说实话,很多嵌入式工程师对单元测试的态度是「知道很重要,但就是懒得写」。我以前也是这样,总觉得嵌入式代码跑在硬件上,测不测无所谓。直到有一次,一个简单的 if 条件写反了,导致产线上批量烧录的板子有一半功能异常……嗯,从那以后,我再也不敢跳过单元测试了。

今天咱们聊聊 CUnit,一个轻量级的 C 语言单元测试框架。它不复杂,但用好了能帮你省下大量调试时间。

什么是 CUnit?

CUnit 是一个专门为 C 语言设计的单元测试框架。它的核心思路很简单:你写测试用例,它帮你自动运行并报告结果。说白了,就是把你手动验证代码的过程自动化了。

我个人习惯把 CUnit 和 assert 宏做对比:assert 只能告诉你「这里出错了」,而 CUnit 能告诉你「哪个测试用例失败了、期望值是多少、实际值是多少」。你想想看,哪个更适合做回归测试?

核心概念速览:

  • 测试注册(Test Registry):管理所有测试套件和用例的容器
  • 测试套件(Test Suite):一组相关的测试用例集合
  • 测试用例(Test Case):单个测试函数,验证一个具体行为
  • 断言宏(Assert Macros):判断测试是否通过的检查点
CUnit 测试框架 测试注册器 Test Registry 测试套件 Test Suite 断言宏 Assert Macros 初始化函数 清理函数 测试用例列表 CU_ASSERT CU_ASSERT_EQUAL CU_ASSERT_STRING CUnit 三层架构:注册器 → 套件 → 用例

安装与配置

CUnit 的安装方式取决于你的开发环境。在 Linux 上,我一般直接用包管理器搞定:

# Ubuntu/Debian
sudo apt-get install libcunit1 libcunit1-dev

# CentOS/RHEL
sudo yum install cunit cunit-devel

# macOS (Homebrew)
brew install cunit

如果你在嵌入式交叉编译环境中使用,可能需要从源码编译。不过大多数情况下,用包管理器就够了。

小提示:编译时记得链接 -lcunit 库。我见过不少新手写了半天测试代码,结果编译时报「未定义引用」的错误,其实就是忘了加链接参数。

一个完整的测试示例

咱们直接上手写一个。假设你有一个计算器模块,包含加法和除法两个函数:

/* calculator.h */
#ifndef CALCULATOR_H
#define CALCULATOR_H

int add(int a, int b);
int divide(int a, int b, int *remainder);

#endif
/* calculator.c */
#include "calculator.h"

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

int divide(int a, int b, int *remainder) {
    if (b == 0) {
        return 0;  /* 除零错误 */
    }
    if (remainder) {
        *remainder = a % b;
    }
    return a / b;
}

现在我们来写测试代码。这是 CUnit 测试文件的核心结构:

/* test_calculator.c */
#include <CUnit/CUnit.h>
#include <CUnit/Basic.h>
#include "calculator.h"

/* 测试用例:测试加法 */
void test_add_positive(void) {
    CU_ASSERT(add(2, 3) == 5);
    CU_ASSERT(add(0, 0) == 0);
    CU_ASSERT(add(100, 200) == 300);
}

void test_add_negative(void) {
    CU_ASSERT(add(-1, 1) == 0);
    CU_ASSERT(add(-5, -3) == -8);
}

/* 测试用例:测试除法 */
void test_divide_normal(void) {
    int rem;
    int result = divide(10, 3, &rem);
    CU_ASSERT(result == 3);
    CU_ASSERT(rem == 1);
}

void test_divide_by_zero(void) {
    int rem;
    int result = divide(5, 0, &rem);
    CU_ASSERT(result == 0);  /* 期望返回0表示错误 */
}

/* 测试套件的初始化与清理 */
int init_suite(void) {
    /* 这里可以做一些初始化工作,比如分配内存 */
    return 0;
}

int clean_suite(void) {
    /* 清理工作 */
    return 0;
}

int main() {
    CU_pSuite pSuite = NULL;

    /* 1. 初始化测试注册器 */
    if (CU_initialize_registry() != CUE_SUCCESS) {
        return CU_get_error();
    }

    /* 2. 添加测试套件 */
    pSuite = CU_add_suite("Calculator_Test_Suite", init_suite, clean_suite);
    if (pSuite == NULL) {
        CU_cleanup_registry();
        return CU_get_error();
    }

    /* 3. 向套件中添加测试用例 */
    if (CU_add_test(pSuite, "test of add positive", test_add_positive) == NULL ||
        CU_add_test(pSuite, "test of add negative", test_add_negative) == NULL ||
        CU_add_test(pSuite, "test of divide normal", test_divide_normal) == NULL ||
        CU_add_test(pSuite, "test of divide by zero", test_divide_by_zero) == NULL) {
        CU_cleanup_registry();
        return CU_get_error();
    }

    /* 4. 运行所有测试(使用基本模式输出) */
    CU_basic_set_mode(CU_BRM_VERBOSE);
    CU_basic_run_tests();

    /* 5. 清理 */
    CU_cleanup_registry();
    return 0;
}

编译运行:

gcc -o test_calculator test_calculator.c calculator.c -lcunit
./test_calculator

输出效果大概是这样:

     CUnit - A unit testing framework for C - Version 2.1-3
     http://cunit.sourceforge.net/

Suite: Calculator_Test_Suite
  Test: test of add positive ... passed
  Test: test of add negative ... passed
  Test: test of divide normal ... passed
  Test: test of divide by zero ... passed

--Run Summary: Type      Total    Ran  Passed  Failed
               suites      1       1      1       0
               tests       4       4      4       0
               asserts    10      10     10       0

常用的断言宏

CUnit 提供了丰富的断言宏。我整理了一份常用清单:

宏名称 用途 示例
CU_ASSERT(expr) 判断表达式为真 CU_ASSERT(ptr != NULL);
CU_ASSERT_EQUAL(actual, expected) 判断两个整数相等 CU_ASSERT_EQUAL(result, 42);
CU_ASSERT_DOUBLE_EQUAL(actual, expected, epsilon) 判断浮点数近似相等 CU_ASSERT_DOUBLE_EQUAL(pi, 3.14159, 0.0001);
CU_ASSERT_STRING_EQUAL(actual, expected) 判断字符串相等 CU_ASSERT_STRING_EQUAL(name, "Alice");
CU_ASSERT_PTR_EQUAL(actual, expected) 判断指针相等 CU_ASSERT_PTR_EQUAL(p, &buffer[0]);
CU_ASSERT_PTR_NULL(ptr) 判断指针为 NULL CU_ASSERT_PTR_NULL(freed_ptr);
CU_FAIL(message) 强制标记测试失败 CU_FAIL("不应该执行到这里");

注意:浮点数比较千万别直接用 CU_ASSERT_EQUAL。我曾经在一个项目中看到有人用 CU_ASSERT_EQUAL(1.0/3.0, 0.333333),结果测试时有时通过有时失败,折腾了半天才发现是浮点精度问题。用 CU_ASSERT_DOUBLE_EQUAL 加上合适的 epsilon 才是正道。

测试运行模式

CUnit 支持三种输出模式,我一般根据场景切换:

  • CU_BRM_NORMAL:只显示失败信息,适合快速检查
  • CU_BRM_VERBOSE:显示所有测试的详细信息,适合调试
  • CU_BRM_SILENT:不输出到控制台,适合自动化脚本

设置方式很简单:

CU_basic_set_mode(CU_BRM_VERBOSE);  /* 或者 NORMAL / SILENT */

嵌入式环境中的注意事项

在嵌入式开发中跑单元测试,有几个坑我得提醒你:

  • 硬件依赖:如果你的代码直接操作寄存器或外设,单元测试在 PC 上跑不了。我习惯把硬件抽象层(HAL)抽出来,测试时用 mock 替换。
  • 内存限制:嵌入式设备内存小,但 CUnit 本身很轻量,几百字节就能跑。不过别在目标板上跑大量测试,建议在开发主机上交叉编译测试。
  • 交叉编译:用你的交叉编译器编译测试程序,然后在目标板上运行。或者用 QEMU 模拟运行。

我的经验:在嵌入式项目中,我通常把单元测试放在 CI 流程中。每次提交代码,CI 服务器用 x86 环境编译并运行测试。这样既能快速发现问题,又不用依赖硬件。只有通过了单元测试的代码,才会进入硬件集成测试阶段。

总结

CUnit 虽然功能不算花哨,但胜在简单可靠。你只需要掌握三个步骤:注册套件、添加用例、运行测试。剩下的就是写断言了。

说实话,写单元测试一开始会觉得麻烦,但当你改完一个函数,跑一下测试发现所有用例都通过时,那种踏实感是调试器给不了的。尤其是做回归测试,改完代码一键验证,比手动点来点去高效太多了。

嗯,今天就聊到这儿。下次你写 C 代码的时候,不妨试试给核心函数加上 CUnit 测试。相信我,你会爱上这种「代码改完心里有底」的感觉。


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