第六章 参数化测试:数据驱动测试、测试用例生成、批量测试技巧
说实话,很多工程师写单元测试,最头疼的就是「重复劳动」。
测一个函数,输入不同参数,逻辑一模一样,却要复制粘贴几十遍测试代码。我早期也这么干过,直到维护时改了一个用例忘了改另一个,踩了坑才学乖。
参数化测试,就是专门解决这个问题的。
6.1 什么是参数化测试
简单说,就是把测试数据从测试逻辑里抽出来。
测试逻辑只写一遍,数据放在表格、数组或文件里。跑测试时,框架自动把每组数据塞进去执行。
这样做的好处很明显:
- 减少重复代码 —— 一个测试函数搞定 N 个场景
- 数据与逻辑分离 —— 改数据不用动代码,非开发人员也能维护
- 覆盖率容易量化 —— 数一数数据组数就知道测了多少种情况
核心思想: 测试用例 = 测试逻辑 + 测试数据。两者解耦,各自独立演进。
6.2 C 语言中的参数化实现方式
C 语言没有像 JUnit 那样的 @ParameterizedTest 注解。但别急,我们有更灵活的手段。
6.2.1 宏定义 + 表格驱动
我个人最常用的是「表格驱动测试」。说白了,就是定义一个结构体数组,每个元素是一组输入和期望输出。
// 被测函数:计算两个整数的最大公约数
int gcd(int a, int b) {
while (b) {
int t = b;
b = a % b;
a = t;
}
return a;
}
// 测试数据表
typedef struct {
int a;
int b;
int expected;
} TestCase;
TestCase test_data[] = {
{12, 8, 4},
{100, 25, 25},
{7, 13, 1},
{0, 5, 5},
{18, 0, 18},
{-6, 9, 3}, // 负数情况
};
// 测试宏
#define RUN_PARAM_TEST(func, data, count) \
do { \
for (int i = 0; i < count; i++) { \
int result = func(data[i].a, data[i].b); \
if (result != data[i].expected) { \
printf("[FAIL] Test %d: gcd(%d,%d) = %d, expected %d\n", \
i, data[i].a, data[i].b, result, data[i].expected); \
} else { \
printf("[PASS] Test %d\n", i); \
} \
} \
} while(0)
// 调用
int main() {
int count = sizeof(test_data) / sizeof(test_data[0]);
RUN_PARAM_TEST(gcd, test_data, count);
return 0;
}
你看,测试逻辑只写了一次。想加新用例?往 test_data 数组里加一行就行。
小技巧: 我习惯把测试数据放在单独的头文件里,比如 test_gcd_data.h。这样测试代码和数据文件分开,多人协作时不容易冲突。
6.2.2 使用函数指针实现多态测试
有时候,被测函数不止一个。比如你实现了两种排序算法,想用同一套数据验证它们。
typedef int (*SortFunc)(int* arr, int len);
typedef struct {
int* input;
int len;
int* expected;
} SortTestCase;
void run_sort_test(SortFunc func, SortTestCase* cases, int count) {
for (int i = 0; i < count; i++) {
int* copy = malloc(cases[i].len * sizeof(int));
memcpy(copy, cases[i].input, cases[i].len * sizeof(int));
func(copy, cases[i].len);
// 比较结果...
free(copy);
}
}
// 使用
SortTestCase sort_data[] = { /* ... */ };
run_sort_test(bubble_sort, sort_data, 3);
run_sort_test(quick_sort, sort_data, 3);
嗯,这里要注意:函数指针的类型必须严格匹配。我曾经因为参数类型写错,排查了半天才发现是 int* 和 const int* 不兼容。
6.3 测试用例生成策略
手动写测试数据,总有遗漏。我建议用「边界 + 随机 + 组合」三种策略覆盖。
6.3.1 边界值分析
80% 的 bug 出在边界上。这是经验之谈。
| 数据类型 | 边界值 | 说明 |
|---|---|---|
| 整数 | INT_MIN, -1, 0, 1, INT_MAX | 注意溢出场景 |
| 指针 | NULL, 有效地址, 对齐/未对齐地址 | 嵌入式里对齐问题很常见 |
| 数组 | 空数组, 单元素, 满数组 | 长度 0 的情况经常被忽略 |
| 字符串 | 空串, 单字符, 含 '\0' 中间 | C 字符串的坑大家都懂 |
避坑指南: 我曾经在测试一个环形缓冲区时,只测了正常读写。结果上线后,当缓冲区满时写入导致覆盖旧数据,丢了一整批传感器数据。从那以后,我每个模块的测试用例里,边界值至少占 40%。
6.3.2 随机测试生成
手动写 100 组数据太累。用随机生成器,配合断言,能快速发现意外情况。
#include <stdlib.h>
#include <time.h>
void test_gcd_random() {
srand(time(NULL));
for (int i = 0; i < 1000; i++) {
int a = rand() % 2000 - 1000; // -1000 ~ 999
int b = rand() % 2000 - 1000;
int result = gcd(a, b);
// 验证:gcd 必须能整除 a 和 b
assert(a % result == 0);
assert(b % result == 0);
// 验证:gcd 是最大公约数,任何更大的数都不能同时整除 a 和 b
for (int d = result + 1; d <= abs(a) && d <= abs(b); d++) {
assert(!(a % d == 0 && b % d == 0));
}
}
printf("[PASS] Random test: 1000 cases\n");
}
随机测试不能替代边界测试,但能帮你发现「想都没想到」的 bug。
6.3.3 组合测试(Pairwise)
当输入参数有多个维度时,全组合测试会导致用例爆炸。比如 5 个参数,每个 4 种取值,全组合是 4^5 = 1024 组。
Pairwise 方法保证任意两个参数的组合都被覆盖到,但用例数大幅减少。对于嵌入式软件,我通常用 Pairwise 生成 80% 的用例,再手动补充关键场景。
6.4 批量测试技巧
参数化测试做完了,怎么批量跑?怎么知道哪些过了哪些没过?
6.4.1 测试结果汇总
我习惯在测试框架里加一个计数器:
static int total_tests = 0;
static int passed_tests = 0;
#define ASSERT_EQ(actual, expected) \
do { \
total_tests++; \
if ((actual) == (expected)) { \
passed_tests++; \
} else { \
printf("[FAIL] Line %d: %s == %s failed\n", \
__LINE__, #actual, #expected); \
} \
} while(0)
// 测试结束后输出汇总
printf("Results: %d/%d passed (%.1f%%)\n",
passed_tests, total_tests,
100.0 * passed_tests / total_tests);
6.4.2 测试分组与标签
当测试用例多了,你需要能只跑某一类。比如只跑「边界测试」或「性能测试」。
typedef enum {
TAG_SMOKE, // 冒烟测试
TAG_BOUNDARY, // 边界测试
TAG_RANDOM, // 随机测试
TAG_PERF // 性能测试
} TestTag;
typedef struct {
TestCase data;
TestTag tag;
const char* name;
} TaggedTestCase;
TaggedTestCase all_tests[] = {
{{12, 8, 4}, TAG_SMOKE, "basic_case"},
{{INT_MAX, 1, 1}, TAG_BOUNDARY, "max_int"},
// ...
};
void run_tests_by_tag(TestTag target_tag) {
for (int i = 0; i < count; i++) {
if (all_tests[i].tag == target_tag) {
// 执行测试
}
}
}
你想想看,如果每次改了一行代码都要跑全部 5000 个用例,那得多浪费时间。按标签分组跑,开发阶段只跑冒烟和边界,提测前跑全量,效率高很多。
6.4.3 测试数据的外部化
对于嵌入式项目,测试数据可能来自真实硬件采集。我习惯把数据存成 CSV 或二进制文件,测试程序启动时加载。
// 从 CSV 文件加载测试数据
int load_test_data(const char* filename, TestCase* buffer, int max_count) {
FILE* fp = fopen(filename, "r");
if (!fp) return -1;
int count = 0;
while (fscanf(fp, "%d,%d,%d\n",
&buffer[count].a,
&buffer[count].b,
&buffer[count].expected) == 3) {
count++;
if (count >= max_count) break;
}
fclose(fp);
return count;
}
这样做的好处是:测试数据可以由测试工程师或现场人员提供,你只需要保证测试逻辑正确。
6.5 本章知识体系
下面这张图总结了参数化测试的核心脉络:
6.6 避坑与建议
- 不要过度参数化 —— 如果某个测试只有 2-3 组数据,硬写成参数化反而增加阅读成本。我一般以 5 组为界,超过 5 组才考虑参数化。
- 测试数据要可读 —— 给每组数据加个名字或注释,否则别人看不懂「12, 8, 4」是什么意思。
- 随机测试要固定种子 —— 调试时用固定种子复现问题,上线前再用随机种子跑一遍。
- 注意测试数据的内存管理 —— 如果数据里有动态分配的内存,记得在测试结束后释放。我曾经因为忘记释放,导致 CI 服务器跑几次就 OOM 了。
我的习惯: 每个模块的测试文件里,开头放一个「测试数据总表」的注释,列出所有测试场景和设计意图。这样半年后回来看,还能快速捡起来。
参数化测试,说白了就是「用数据驱动测试,而不是用代码堆砌测试」。掌握它,你的单元测试会从「体力活」变成「脑力活」。
公众号:蓝海资料掘金营,微信deep3321