实战案例11:自动化测试工具

说实话,做C语言开发这么多年,我踩过最大的坑就是——手工测试

你想想看,一个正则表达式引擎,几十个模式,几百个测试用例。每次改一行代码,就要手动跑一遍?我年轻时干过这事,结果有一次漏测了一个边界情况,线上出了bug,被老大叫去喝茶。

从那以后,我就养成了一个习惯:能自动化的事,绝不手工做

今天这个实战案例,就是教你写一个轻量级的自动化测试工具。它不依赖任何第三方框架,纯C语言实现。核心功能包括:

  • 自动读取测试用例文件
  • 自动生成测试数据
  • 验证输出格式
  • 支持回归测试

说白了,就是让你从重复劳动中解放出来。

整体架构设计

先看一张图,了解整个工具的运行流程:

自动化测试工具架构图 测试用例文件 test_cases.txt 读取 测试引擎 test_runner.c 数据生成器 data_gen.c 输出验证器 validator.c 测试报告 report.txt

整个工具分三层:

  1. 输入层:测试用例文件,用简单格式描述每个测试场景
  2. 核心引擎:解析用例、调用正则库、收集结果
  3. 验证层:对比预期输出与实际输出,生成报告

我个人习惯把测试用例和数据生成分开。为什么?因为数据生成往往需要随机性,而用例本身是固定的。分开后,你可以单独调试数据生成逻辑,不会影响用例结构。

测试用例文件格式

先定义用例文件的格式。我选的是最简单的文本格式,每行一个字段:

# 测试用例文件:test_cases.txt
# 格式:模式 | 输入字符串 | 预期匹配结果 | 预期捕获组

pattern: ^[a-zA-Z0-9]+@[a-zA-Z]+\.(com|cn)$
input: hello@example.com
expected_match: 1
expected_groups: hello@example.com, com
---
pattern: \d{3}-\d{8}
input: 010-12345678
expected_match: 1
expected_groups: 010-12345678
---
pattern: \d{3}-\d{8}
input: 010-1234567
expected_match: 0
expected_groups: 
---

每个用例用 --- 分隔。字段名后面跟冒号和空格,解析起来很方便。

嗯,这里要注意:空行和注释行要能跳过。我见过不少工具因为解析器太脆弱,加个空行就崩了。

核心代码实现

下面给出测试引擎的核心代码。我尽量精简,但保留了关键逻辑:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <regex.h>

#define MAX_LINE 1024
#define MAX_CASES 1000

typedef struct {
    char pattern[256];
    char input[512];
    int expected_match;
    char expected_groups[512];
} TestCase;

// 解析测试用例文件
int parse_test_file(const char *filename, TestCase *cases) {
    FILE *fp = fopen(filename, "r");
    if (!fp) {
        perror("无法打开测试文件");
        return -1;
    }

    char line[MAX_LINE];
    int count = 0;
    TestCase current;
    memset(&current, 0, sizeof(current));

    while (fgets(line, sizeof(line), fp)) {
        // 跳过注释和空行
        if (line[0] == '#' || line[0] == '\n' || line[0] == '\r') {
            continue;
        }

        // 去除换行符
        line[strcspn(line, "\r\n")] = 0;

        // 用例分隔符
        if (strcmp(line, "---") == 0) {
            if (strlen(current.pattern) > 0) {
                cases[count++] = current;
                memset(&current, 0, sizeof(current));
            }
            continue;
        }

        // 解析字段
        char key[64], value[512];
        if (sscanf(line, "%63[^:]: %511[^\n]", key, value) == 2) {
            if (strcmp(key, "pattern") == 0) {
                strncpy(current.pattern, value, sizeof(current.pattern)-1);
            } else if (strcmp(key, "input") == 0) {
                strncpy(current.input, value, sizeof(current.input)-1);
            } else if (strcmp(key, "expected_match") == 0) {
                current.expected_match = atoi(value);
            } else if (strcmp(key, "expected_groups") == 0) {
                strncpy(current.expected_groups, value, sizeof(current.expected_groups)-1);
            }
        }
    }

    // 处理最后一个用例
    if (strlen(current.pattern) > 0) {
        cases[count++] = current;
    }

    fclose(fp);
    return count;
}

// 执行单个测试
int run_test(TestCase *tc) {
    regex_t regex;
    regmatch_t pmatch[10];
    int ret;

    ret = regcomp(&regex, tc->pattern, REG_EXTENDED);
    if (ret != 0) {
        printf("编译正则失败: %s\n", tc->pattern);
        return 0;
    }

    ret = regexec(&regex, tc->input, 10, pmatch, 0);
    regfree(&regex);

    int actual_match = (ret == 0) ? 1 : 0;
    return (actual_match == tc->expected_match);
}

这段代码我用了很多年了。核心思路就一个:把测试用例结构化,然后批量执行

我曾经在一个嵌入式项目里,用类似的代码跑了上千个正则测试,全部跑完不到1秒。比手工测试快太多了。

自动生成测试数据

有时候,光靠手写用例不够。你需要大量随机数据来压测正则引擎的稳定性。

我写了一个简单的数据生成器,专门生成邮箱、手机号、IP地址等常见格式:

#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <string.h>

void gen_email(char *buf, int len) {
    char *domains[] = {"gmail.com", "163.com", "qq.com", "outlook.com"};
    char *names[] = {"alice", "bob", "test", "user", "admin", "dev"};
    int name_idx = rand() % 6;
    int domain_idx = rand() % 4;
    int num = rand() % 1000;
    snprintf(buf, len, "%s%d@%s", names[name_idx], num, domains[domain_idx]);
}

void gen_phone(char *buf, int len) {
    char *prefix[] = {"138", "139", "150", "186", "188"};
    int p = rand() % 5;
    int mid = 1000 + rand() % 9000;
    int tail = 1000 + rand() % 9000;
    snprintf(buf, len, "%s-%04d-%04d", prefix[p], mid, tail);
}

int main() {
    srand(time(NULL));
    char email[128], phone[128];

    for (int i = 0; i < 10; i++) {
        gen_email(email, sizeof(email));
        gen_phone(phone, sizeof(phone));
        printf("email: %s\nphone: %s\n---\n", email, phone);
    }
    return 0;
}

你想想看,如果手工写100个测试邮箱,得花多少时间?用这个生成器,一秒搞定。而且每次运行数据都不一样,能发现一些边界情况。

输出格式验证

验证输出格式,说白了就是检查正则匹配的结果是否符合预期。我封装了一个验证函数:

int validate_output(const char *pattern, const char *input,
                    int expected_match, const char *expected_groups) {
    regex_t regex;
    regmatch_t pmatch[10];
    int ret;

    ret = regcomp(&regex, pattern, REG_EXTENDED);
    if (ret != 0) return -1;

    ret = regexec(&regex, input, 10, pmatch, 0);
    regfree(&regex);

    int actual_match = (ret == 0) ? 1 : 0;
    if (actual_match != expected_match) {
        printf("FAIL: 匹配结果不一致\n");
        printf("  预期: %d, 实际: %d\n", expected_match, actual_match);
        return 0;
    }

    // 如果预期匹配,进一步验证捕获组
    if (actual_match == 1 && strlen(expected_groups) > 0) {
        // 这里可以解析 expected_groups 并逐个对比
        // 为简化,只打印提示
        printf("PASS: 匹配成功,捕获组待详细验证\n");
    }

    return 1;
}

这个函数会返回三个状态:1 表示通过,0 表示失败,-1 表示编译错误。我在项目中通常把结果汇总到一个结构体里,最后统一输出报告。

回归测试策略

回归测试,说白了就是每次改完代码后,把之前所有用例再跑一遍。确保新功能没把旧功能搞坏。

我的做法很简单:

  • 维护一个 test_cases.txt 文件,里面放所有历史用例
  • 每次提交代码前,执行 ./test_runner
  • 如果所有用例通过,输出 ALL PASS
  • 如果有失败,输出具体失败的用例编号和原因

我曾经在一个项目中,因为改了一个正则的边界处理,导致三个旧用例失败。幸亏有回归测试,不然上线后用户就会发现某些邮箱格式匹配不到了。

核心要点:回归测试不是可选项,而是必选项。哪怕只改了一行代码,也要跑一遍全部用例。这是对自己负责,也是对团队负责。

测试报告生成

最后,把测试结果输出到文件。我习惯用简单的文本格式,方便后续用脚本分析:

void generate_report(TestCase *cases, int *results, int count) {
    FILE *fp = fopen("report.txt", "w");
    if (!fp) return;

    int pass = 0, fail = 0;
    fprintf(fp, "=== 自动化测试报告 ===\n");
    fprintf(fp, "生成时间: %s\n", __DATE__);
    fprintf(fp, "总用例数: %d\n\n", count);

    for (int i = 0; i < count; i++) {
        fprintf(fp, "用例 #%d: ", i+1);
        if (results[i] == 1) {
            fprintf(fp, "PASS\n");
            pass++;
        } else {
            fprintf(fp, "FAIL\n");
            fprintf(fp, "  模式: %s\n", cases[i].pattern);
            fprintf(fp, "  输入: %s\n", cases[i].input);
            fail++;
        }
    }

    fprintf(fp, "\n通过: %d, 失败: %d\n", pass, fail);
    fprintf(fp, "通过率: %.1f%%\n", (float)pass/count*100);
    fclose(fp);
}

小技巧:我习惯在报告里加上时间戳和版本号。这样回头查问题的时候,能知道是哪个版本的代码跑出了什么结果。别小看这个细节,关键时刻能救命。

避坑指南

最后分享几个我踩过的坑:

  • 正则编译失败要提前捕获:不要等到执行时才报错。我建议在解析用例阶段就编译一次,如果失败直接跳过并记录。
  • 捕获组数量要匹配:如果预期有3个捕获组,但实际只匹配到2个,要明确报错。我曾经因为这个原因,漏掉了一个子模式的bug。
  • 测试数据要可复现:随机生成数据时,建议把种子(seed)记录下来。这样如果发现bug,可以用同样的种子复现。

警告:不要在生产环境直接运行未经测试的正则表达式。尤其是用户输入的正则,可能包含恶意模式导致拒绝服务(ReDoS)。建议在测试工具中加入超时机制。

好了,这一章的内容就到这里。自动化测试工具的核心思路你已经掌握了。下一章我们会深入一个更复杂的实战案例——日志分析系统,到时候会用到本章的测试框架来验证日志解析的正确性。


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