17、单元测试框架:CUnit、Unity、Check的跨平台集成与测试用例编写

单元测试这件事,说实话,在C语言圈子里一直有点「边缘」。很多人觉得C就是写底层、写驱动的,跑什么单元测试?我早年也这么想,直到有一次在嵌入式项目里,一个指针越界的问题查了整整三天——后来发现,如果当时写了单元测试,十分钟就能定位到。

从那以后,我养成了一个习惯:任何跨平台项目,第一件事就是搭好单元测试框架。今天咱们就聊聊三个主流的C单元测试框架:CUnit、Unity、Check。它们各有脾气,选对了能省不少事。

核心观点:单元测试不是「加分项」,而是跨平台开发的「安全带」。没有测试的代码重构,就像蒙眼走钢丝。

17.1 三大框架的定位与选型

先给个总览,方便你快速判断该用哪个。

框架 特点 适合场景 跨平台支持
CUnit 功能全面,类似JUnit 桌面端、Linux服务器 Windows/Linux/macOS
Unity 极简、无动态内存分配 嵌入式、MCU、RTOS 任何有C编译器的平台
Check 支持fork模式,隔离性好 需要高隔离性的测试 主要Linux/Unix

我个人习惯是:做嵌入式用Unity,做服务器端用CUnit,做安全敏感模块用Check。为什么这么分?往下看你就明白了。

17.2 CUnit:功能最全,但有点「重」

CUnit是我最早接触的C测试框架。它的设计思路很清晰:测试注册、测试运行、结果输出,三步走。说白了,就是把JUnit那套搬到了C里。

来看一个实际例子。假设我们要测试一个跨平台的字符串处理函数:

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

// 被测试函数
int string_length(const char* str) {
    if (str == NULL) return -1;
    int len = 0;
    while (str[len] != '\0') len++;
    return len;
}

// 测试用例
void test_string_length_normal(void) {
    CU_ASSERT(string_length("hello") == 5);
    CU_ASSERT(string_length("") == 0);
}

void test_string_length_null(void) {
    CU_ASSERT(string_length(NULL) == -1);
}

int main() {
    CU_initialize_registry();
    CU_pSuite suite = CU_add_suite("String Tests", NULL, NULL);
    CU_add_test(suite, "test normal", test_string_length_normal);
    CU_add_test(suite, "test null", test_string_length_null);
    CU_basic_run_tests();
    CU_cleanup_registry();
    return 0;
}

这里有个坑,我曾经踩过:CUnit的初始化必须在所有测试之前调用。如果你在多个源文件里分散注册测试,一定要保证初始化只执行一次。不然会出现「测试注册成功但运行时报段错误」的诡异问题。

小技巧:在跨平台项目中,建议把CUnit的初始化封装成一个函数,在main()里统一调用。这样Windows和Linux的入口点不同时,只需要改main()就行。

17.3 Unity:嵌入式开发的「轻骑兵」

Unity是我在嵌入式项目里用得最多的框架。为什么?因为它不需要动态内存分配。你想想看,很多MCU上根本没有malloc,或者malloc的堆小得可怜。Unity完全用宏和静态数组实现,跑在裸机上毫无压力。

Unity的测试用例写法非常简洁:

#include "unity.h"

void setUp(void) {}
void tearDown(void) {}

void test_crc_calculation(void) {
    uint8_t data[] = {0x01, 0x02, 0x03};
    uint16_t crc = calculate_crc16(data, 3);
    TEST_ASSERT_EQUAL_HEX16(0x4B37, crc);
}

void test_crc_empty(void) {
    uint16_t crc = calculate_crc16(NULL, 0);
    TEST_ASSERT_EQUAL_HEX16(0x0000, crc);
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_crc_calculation);
    RUN_TEST(test_crc_empty);
    return UNITY_END();
}

注意看,Unity没有CUnit那种「注册套件」的流程。它直接调用RUN_TEST宏,简单粗暴。我个人觉得这种方式在嵌入式开发里更直观——你不需要理解「测试注册表」这种抽象概念,直接写测试、跑测试就行。

注意:Unity的setUp和tearDown函数是全局的。如果你有多个测试文件,每个文件都需要定义自己的setUp/tearDown。我曾经在一个项目里忘了在某个文件里定义tearDown,结果测试资源泄漏,跑了几百个用例后内存爆了。

17.4 Check:隔离性最强的「安全卫士」

Check最大的特点是支持fork模式。每个测试用例在独立的子进程中运行,就算测试崩溃了,也不会影响主测试进程。这对测试那些「可能让程序挂掉」的边界情况特别有用。

举个例子,测试一个内存拷贝函数:

#include <check.h>

START_TEST(test_memcpy_normal) {
    char src[] = "hello";
    char dst[10] = {0};
    memcpy(dst, src, 6);
    ck_assert_str_eq(dst, "hello");
}
END_TEST

START_TEST(test_memcpy_overflow) {
    char src[] = "hello";
    char dst[3] = {0};
    // 这个测试可能会崩溃,但Check能捕获
    memcpy(dst, src, 6);
    ck_assert_str_eq(dst, "hel");
}
END_TEST

Suite* memcpy_suite(void) {
    Suite* s = suite_create("Memcpy");
    TCase* tc_core = tcase_create("Core");
    tcase_add_test(tc_core, test_memcpy_normal);
    tcase_add_test(tc_core, test_memcpy_overflow);
    suite_add_tcase(s, tc_core);
    return s;
}

这里有个关键点:Check的fork模式在Windows上支持不太好。我记得有一次在Windows CI上跑测试,所有fork测试都报「未实现」。后来查文档才发现,Windows上Check默认用线程模式,隔离性会差一些。所以如果你做纯Windows开发,Check可能不是最佳选择。

17.5 跨平台集成的实战经验

说了这么多,咱们来点实际的。怎么在跨平台项目里集成这些框架?我总结了一套「三步走」策略:

  1. 用CMake管理测试构建——不要手写Makefile,CMake的跨平台能力比你想的强。
  2. 把测试框架作为子模块——用git submodule或者FetchContent拉取源码,不要手动复制。
  3. 统一测试入口——所有测试用例通过一个main()调度,方便CI集成。

下面是一个CMakeLists.txt的示例,支持三个框架的自动检测:

cmake_minimum_required(VERSION 3.14)
project(MyProject C)

# 检测可用框架
find_package(CUnit QUIET)
find_package(Check QUIET)

# Unity直接内嵌
add_subdirectory(third_party/unity)

# 根据平台选择框架
if(CUNIT_FOUND)
    add_executable(test_runner test_main.c test_cunit.c)
    target_link_libraries(test_runner CUnit::CUnit)
elseif(CHECK_FOUND AND NOT WIN32)
    add_executable(test_runner test_main.c test_check.c)
    target_link_libraries(test_runner Check::Check)
else()
    add_executable(test_runner test_main.c test_unity.c)
    target_link_libraries(test_runner unity)
endif()

这个做法的好处是:在Linux上优先用CUnit或Check,在Windows或嵌入式上自动回退到Unity。我在一个跨平台SDK项目里用了这个方案,三个平台的CI全部跑通,再也没出现过「Windows上测试编译不过」的尴尬。

17.6 测试用例编写的「避坑指南」

写测试用例看起来简单,但实际坑不少。我把自己踩过的坑整理一下:

  • 不要依赖测试执行顺序——CUnit的测试顺序是注册顺序,Unity是调用顺序,但你不应该依赖这个。每个测试用例应该是独立的。
  • 注意全局状态污染——我曾经在测试A里设置了一个全局标志,测试B依赖这个标志。结果后来测试A被跳过了,测试B就挂了。解决方案:每个测试用例前重置全局状态。
  • 浮点数比较用宏——CUnit有CU_ASSERT_DOUBLE_EQUAL,Unity有TEST_ASSERT_FLOAT_WITHIN。直接比较浮点数等于,十有八九会失败。
  • 测试边界条件——空指针、零长度、最大值、最小值,这些边界最容易出bug。我见过太多「正常情况全过,边界情况全崩」的项目了。

我的经验:一个高质量的测试用例,应该覆盖「正常路径」、「异常路径」和「边界条件」三个维度。只测正常路径的测试,说白了就是「安慰剂」。

17.7 知识体系总览

下面这张图总结了本章的核心内容,方便你快速回顾:

C语言单元测试框架知识体系 CUnit Unity Check 功能全面 类似JUnit 极简无malloc 嵌入式首选 fork隔离 跨平台集成策略 CMake管理构建 子模块管理 统一测试入口

这张图把三大框架的特点和跨平台集成策略串起来了。你可以看到,选框架不是「哪个最好」,而是「哪个最适合你的场景」。桌面端用CUnit,嵌入式用Unity,需要高隔离性用Check——就这么简单。

17.8 写在最后

单元测试这件事,说白了就是「花时间省时间」。刚开始写测试的时候,你会觉得「这玩意儿真麻烦,还不如直接跑程序」。但等你遇到那种「改了一行代码,炸了三个模块」的情况,你就会明白测试的价值了。

我个人建议:从Unity开始入手。它最简单,最不容易出错。等你把测试思维培养起来了,再根据项目需求切换到CUnit或Check。别一上来就想「全都要」,测试框架也是需要学习的,一口吃不成胖子。

嗯,关于单元测试框架,今天就聊到这儿。记住:没有测试的代码,不是完整的代码

最后一个小建议:把测试和代码放在同一个仓库里,每次提交代码前先跑一遍测试。养成这个习惯,你的代码质量会有质的飞跃。

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