第六章 参数化测试:数据驱动测试、测试用例生成、批量测试技巧

说实话,很多工程师写单元测试,最头疼的就是「重复劳动」。

测一个函数,输入不同参数,逻辑一模一样,却要复制粘贴几十遍测试代码。我早期也这么干过,直到维护时改了一个用例忘了改另一个,踩了坑才学乖。

参数化测试,就是专门解决这个问题的。

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 本章知识体系

下面这张图总结了参数化测试的核心脉络:

参数化测试 实现方式 宏 + 表格驱动 函数指针多态 用例生成策略 边界值分析 随机生成 Pairwise 组合 批量测试技巧 结果汇总统计 标签分组运行 数据外部化 目标:一份逻辑,多组数据,高效覆盖

6.6 避坑与建议

  • 不要过度参数化 —— 如果某个测试只有 2-3 组数据,硬写成参数化反而增加阅读成本。我一般以 5 组为界,超过 5 组才考虑参数化。
  • 测试数据要可读 —— 给每组数据加个名字或注释,否则别人看不懂「12, 8, 4」是什么意思。
  • 随机测试要固定种子 —— 调试时用固定种子复现问题,上线前再用随机种子跑一遍。
  • 注意测试数据的内存管理 —— 如果数据里有动态分配的内存,记得在测试结束后释放。我曾经因为忘记释放,导致 CI 服务器跑几次就 OOM 了。

我的习惯: 每个模块的测试文件里,开头放一个「测试数据总表」的注释,列出所有测试场景和设计意图。这样半年后回来看,还能快速捡起来。

参数化测试,说白了就是「用数据驱动测试,而不是用代码堆砌测试」。掌握它,你的单元测试会从「体力活」变成「脑力活」。


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