测试套件管理:把散落的测试用例组织起来

做单元测试,最怕什么?

我个人觉得,最怕的就是测试用例散落一地,想跑一遍还得一个个手动执行。你想想看,一个模块几十个测试函数,每次改完代码都要挨个调用,这谁受得了?

所以,我们需要测试套件(Test Suite)。说白了,它就是一个容器,把相关的测试用例装在一起,统一管理、统一执行。

什么是测试套件?

测试套件就是一组测试用例的集合。你可以按模块划分,也可以按功能划分。比如,我负责的通信协议栈,就会把“CAN驱动测试”、“SPI驱动测试”、“UART驱动测试”分别做成三个套件。

每个套件可以有自己的初始化函数和清理函数。跑测试前先初始化,跑完后做清理。这样每个测试用例就不用重复写初始化和清理代码了。

核心思想:测试套件 = 测试用例集合 + 套件级初始化 + 套件级清理

创建测试套件

在C语言里,我们通常用结构体来表示一个测试套件。我习惯这样定义:

typedef struct {
    const char *suite_name;          // 套件名称
    void (*setup)(void);             // 套件初始化函数
    void (*teardown)(void);          // 套件清理函数
    TestCase *test_cases;            // 测试用例数组
    int test_case_count;             // 测试用例数量
} TestSuite;

嗯,这里要注意:setupteardown是可选函数指针。如果你的测试用例不需要特殊初始化,可以设为NULL。

举个例子,我写过一个内存池模块的测试套件:

void mempool_setup(void) {
    // 初始化内存池,分配测试用的内存块
    mempool_init(&test_pool, TEST_POOL_SIZE);
    printf("[SUITE SETUP] 内存池已初始化\n");
}

void mempool_teardown(void) {
    // 释放内存池,清理资源
    mempool_destroy(&test_pool);
    printf("[SUITE TEARDOWN] 内存池已销毁\n");
}

TestSuite mempool_suite = {
    .suite_name = "内存池测试套件",
    .setup = mempool_setup,
    .teardown = mempool_teardown,
    .test_cases = mempool_tests,
    .test_case_count = 5
};

注册测试用例

测试用例怎么注册到套件里?我通常用一个数组来管理:

typedef struct {
    const char *test_name;           // 测试用例名称
    void (*test_func)(void);         // 测试函数指针
    int expected_result;             // 期望结果(0表示通过)
} TestCase;

// 定义测试用例
void test_mempool_alloc(void) {
    void *ptr = mempool_alloc(&test_pool, 64);
    assert(ptr != NULL);
}

void test_mempool_free(void) {
    void *ptr = mempool_alloc(&test_pool, 64);
    mempool_free(&test_pool, ptr);
    // 验证内存已被回收
    assert(mempool_available(&test_pool) == TEST_POOL_SIZE);
}

// 注册到数组
TestCase mempool_tests[] = {
    {"分配64字节", test_mempool_alloc, 0},
    {"释放内存", test_mempool_free, 0},
    {"分配0字节", test_mempool_alloc_zero, -1},  // 期望失败
    {"重复释放", test_mempool_double_free, -1},  // 期望失败
    {"边界测试", test_mempool_boundary, 0}
};

我在项目中遇到过一个问题:测试用例数组的顺序很重要。如果你把“释放内存”放在“分配内存”前面,那肯定跑不过。所以,注册时一定要考虑依赖关系。

小技巧:我习惯把测试用例按“正向测试 → 边界测试 → 异常测试”的顺序排列。这样跑起来,先验证基本功能,再测边界,最后测异常,逻辑上更清晰。

套件初始化与清理

套件级别的初始化和清理,跟每个测试用例自己的初始化和清理是两回事。我画个图你就明白了:

套件初始化 (setup) 测试用例 1:分配64字节 测试用例 2:释放内存 ... 套件清理 (teardown) 执行流程:setup → 测试用例1 → 测试用例2 → ... → teardown

看到没?setup只执行一次,在所有测试用例之前。teardown也只执行一次,在所有测试用例之后。每个测试用例之间是独立的,互不影响。

套件运行器

有了套件和用例,还得有个运行器来驱动它们。我写了一个简单的运行器:

int run_test_suite(TestSuite *suite) {
    int passed = 0;
    int failed = 0;
    
    printf("========== 开始执行套件: %s ==========\n", suite->suite_name);
    
    // 套件初始化
    if (suite->setup) {
        suite->setup();
    }
    
    // 逐个执行测试用例
    for (int i = 0; i < suite->test_case_count; i++) {
        TestCase *tc = &suite->test_cases[i];
        printf("  [执行] %s ... ", tc->test_name);
        
        int result = 0;
        // 这里用setjmp/longjmp做异常捕获,简化起见不展开
        tc->test_func();
        
        if (result == tc->expected_result) {
            printf("通过\n");
            passed++;
        } else {
            printf("失败\n");
            failed++;
        }
    }
    
    // 套件清理
    if (suite->teardown) {
        suite->teardown();
    }
    
    printf("========== 套件结束: %s ==========\n", suite->suite_name);
    printf("总计: %d, 通过: %d, 失败: %d\n\n", passed + failed, passed, failed);
    
    return failed;
}

避坑指南:我曾经在套件清理时忘记释放全局资源,导致后续套件跑的时候内存泄漏。后来我养成了一个习惯:teardown里一定要把setup分配的所有资源都释放干净。哪怕你觉得“这个资源跑完程序就退出了,不释放也没关系”——千万别这么想!测试框架可能会反复跑套件,资源泄漏会越积越多。

多套件管理

实际项目中,我们往往有多个套件。我习惯用一个全局数组来管理所有套件:

TestSuite *all_suites[] = {
    &mempool_suite,
    &queue_suite,
    &timer_suite,
    &uart_suite,
    NULL  // 结束标记
};

int run_all_tests(void) {
    int total_failed = 0;
    for (int i = 0; all_suites[i] != NULL; i++) {
        total_failed += run_test_suite(all_suites[i]);
    }
    return total_failed;
}

这样,我只需要调用run_all_tests(),就能一次性跑完所有模块的测试。如果只想跑某个模块,也可以单独调用run_test_suite(&mempool_suite)

套件间的依赖处理

有时候,套件之间会有依赖关系。比如,我的“队列测试套件”依赖“内存池测试套件”先跑通。怎么处理?

我的做法是:在套件初始化时做前置检查。

void queue_setup(void) {
    // 检查内存池是否可用
    if (!mempool_is_ready()) {
        printf("[ERROR] 内存池未初始化,队列测试无法执行\n");
        // 可以跳过整个套件,或者直接assert失败
        assert(0);
    }
    // 初始化队列
    queue_init(&test_queue, 10);
}

你想想看,这样做的好处是什么?如果内存池测试没通过,队列测试根本不会开始,避免了“因为底层模块失败导致上层模块一堆假失败”的情况。

总结一下测试套件管理的要点:

  • 用结构体封装套件信息,包括名称、初始化、清理、用例列表
  • 测试用例用数组注册,注意执行顺序
  • 套件级初始化只做一次,不要在每个用例里重复做
  • 套件清理要彻底,释放所有资源
  • 多套件用数组管理,支持全量运行和单独运行
  • 套件间依赖通过前置检查来处理

好了,测试套件管理这块就聊到这儿。记住,好的套件管理能让你的测试工作事半功倍。别让测试用例散落一地,把它们组织起来,统一管理,这才是专业工程师的做法。


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