测试套件管理:把散落的测试用例组织起来
做单元测试,最怕什么?
我个人觉得,最怕的就是测试用例散落一地,想跑一遍还得一个个手动执行。你想想看,一个模块几十个测试函数,每次改完代码都要挨个调用,这谁受得了?
所以,我们需要测试套件(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;
嗯,这里要注意:setup和teardown是可选函数指针。如果你的测试用例不需要特殊初始化,可以设为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只执行一次,在所有测试用例之前。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