实战案例11:自动化测试工具
说实话,做C语言开发这么多年,我踩过最大的坑就是——手工测试。
你想想看,一个正则表达式引擎,几十个模式,几百个测试用例。每次改一行代码,就要手动跑一遍?我年轻时干过这事,结果有一次漏测了一个边界情况,线上出了bug,被老大叫去喝茶。
从那以后,我就养成了一个习惯:能自动化的事,绝不手工做。
今天这个实战案例,就是教你写一个轻量级的自动化测试工具。它不依赖任何第三方框架,纯C语言实现。核心功能包括:
- 自动读取测试用例文件
- 自动生成测试数据
- 验证输出格式
- 支持回归测试
说白了,就是让你从重复劳动中解放出来。
整体架构设计
先看一张图,了解整个工具的运行流程:
整个工具分三层:
- 输入层:测试用例文件,用简单格式描述每个测试场景
- 核心引擎:解析用例、调用正则库、收集结果
- 验证层:对比预期输出与实际输出,生成报告
我个人习惯把测试用例和数据生成分开。为什么?因为数据生成往往需要随机性,而用例本身是固定的。分开后,你可以单独调试数据生成逻辑,不会影响用例结构。
测试用例文件格式
先定义用例文件的格式。我选的是最简单的文本格式,每行一个字段:
# 测试用例文件: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(¤t, 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(¤t, 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(®ex, tc->pattern, REG_EXTENDED);
if (ret != 0) {
printf("编译正则失败: %s\n", tc->pattern);
return 0;
}
ret = regexec(®ex, tc->input, 10, pmatch, 0);
regfree(®ex);
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(®ex, pattern, REG_EXTENDED);
if (ret != 0) return -1;
ret = regexec(®ex, input, 10, pmatch, 0);
regfree(®ex);
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