1. 项目背景:当我们被“手动跑用例”逼到墙角
在软件测试自动化这条路上,我估摸着每个干了两年以上的测试或开发,都经历过一种非常痛苦的场景:测试用例写了一堆,但跑起来却得手动一条条执行,或者来回切换好几个脚本窗口,好不容易跑完还得自己从几十个输出文件里捞失败信息,肉眼对比、手写汇总。尤其是回归测试,一跑就是几百上千个用例,输出几百MB的日志,真正有用的信息像大海捞针。
我自己就吃过这个亏。
有一段时间,我们在做一个底层接口库的回归验证,测试用例按模块分散在不同目录,每个用例是独立的脚本,输出格式还不统一。刚开始图省事,写了个bash循环去挨个调用,确实能跑,但问题很快就冒出来了:某个用例崩了导致整个循环中断;日志越攒越多,最后磁盘爆掉;最要命的是,用例多了以后根本没法快速回答“这次一共跑了多少个、通过几个、失败几个、为什么失败”。
于是我想到了Perl。这个被有些人调侃“像写诗一样难读”的脚本语言,在处理文本、拼接命令、生成报告这些场景下,简直是老本行。Perl跑测试的天然优势在于:它天生就是为文本处理设计的正则引擎,字符串拼接和文件操作极其顺手;它对进程管理的支持非常灵活,可以同步跑、并行跑、超时杀掉;而且Perl不挑平台,Windows、Linux、macOS,只要装了解释器就能跑同一套脚本。
这篇文章,我就把这个完全能落地的Perl测试运行器从需求拆解到代码实现,再到踩坑实录,全部摊开讲清楚。适合手里有一堆测试脚本想统一调度、又不想引入重型测试框架的测试开发、运维和做工具链的后端同学参考。
2. 整体设计思路:先把最要命的三个问题定义清楚
动手写代码前,我建议先别急着敲键。以我踩过的坑来看,任何测试调度工具,如果你一开始不把输入、执行、输出这三件事想明白,后面全是返工。
2.1 用例清单从哪里来——用目录约定代替配置文件
第一种方案,也是最常见的,是搞一个配置文件,把用例路径一股脑列进去。腾讯文档、Excel、XML、YAML都见过,这种方式初期好理解,但一旦用例多了,维护成本就起飞。加用例改配置、删用例改配置、换机器路径不同又要改配置,累得不行。
第二种方案,用目录扫描自动发现测试用例。我把所有测试脚本统一放在约定的目录下,只要命名符合规则,比如以test_开头或者以.t结尾,脚本就自动去发现。这个想法的启发来自Perl生态里的prove工具——TAP协议下的测试文件都叫.t,跑起来非常规整。
我最终选择的是:默认扫描指定目录下匹配*.t和test_*.pl的文件,同时也保留一个可选的配置文件,提供黑名单功能。这样日常增删用例完全不需要动配置,只有特殊场景——比如某些用例在特定环境不允许跑——才需要主动排除。
这种“约定大于配置”的思路,核心收益在于:新加的用例只要丢进目录就能被自动纳管,回归范围是随着代码库变化自然演进的,而不是靠人肉维护列表。
2.2 执行方式选同步还是并行——理解阻塞和资源竞争
这是我在第一版脚本里犯过的最大错误。一开始图省事,用单进程串行跑,几十个用例就要跑大半天,执行时间完全不可接受。
后来改成无脑全量并行,直接把机器跑挂了。原因很简单:测试里一旦有操作共享资源的用例(比如连同一个测试数据库、写同一个临时文件),并行跑起来就是各种相互踩踏,数据库连接数被打爆,临时文件互相覆盖,排查问题的时候根本分不清是代码bug还是并发冲突。
所以我在设计里做了一档折中:默认参数-j 1串行执行,加参数-j N可以并行跑。在并行模式下,每个用例运行在自己的子进程里,通过fork实现(Windows下用proc_open模拟)。所有日志按用例名分开写,避免串扰。
只要你理解了“同步是保险的,并行是危险的,需要你明确知道自己用例有没有共享资源”,调度模型这块就不会摔得太狠。
2.3 日志摘要到底要摘什么——失败才是第一优先级
日志摘要这事,听起来简单,就是把多个日志文件的重点信息整合起来,但实际上很多人做成了“把日志重新打印一遍”,完全没价值。
我给自己定了三条“摘什么”的规则:
第一,每个用例的最终状态(通过、失败、跳过、异常终止),这是最基本的盘点。
第二,失败用例的关键错误签名。只是一句话也行,比如“断言失败: expected 5, got 6”或者“Exception: Connection refused”。这样你不用打开几百MB的日志就能快速定位问题归属。
第三,耗时统计。哪些用例跑得慢,是不是最近改动引入了明显的性能退化,这些数字看趋势比看单次值更有意义。
我用TAP格式来标准化输出——这个其实从Perl的Test::More框架里学来的,它天然就有一个“ok/not ok”的行结构,解析特别方便;如果你的用例输出的是纯文本,我在脚本里也加入了基于退出码回归判断的机制,配合少量的正则匹配来提取“Failed/ERROR/Exception”这类关键行。
3. 核心代码实现:一个不算复杂但非常耐用的Perl脚本
下面进入正题。我把整个脚本拆成几层来讲,每层都会附关键代码和注释。你可以直接照着抄,也可以按需裁剪。
3.1 解析命令行参数——用Getopt::Long,别自己手写参数解析
Perl里面解析命令行参数,标准做法是用Getopt::Long模块。我见过不少新手的做法是手动shift数组,用一堆if去判断参数,我确实也这么写过,然后被折磨了一下。用模块省心太多了:
#!/usr/bin/perl use strict; use warnings; use Getopt::Long; my $dir = './tests'; my $jobs = 1; my $timeout = 300; my $verbose = 0; my $exclude = ''; GetOptions( 'dir=s' => \$dir, 'jobs=i' => \$jobs, 'timeout=i' => \$timeout, 'verbose' => \$verbose, 'exclude=s' => \$exclude, ) or die "Usage: $0 [--dir DIR] [--jobs N] [--timeout SECS] [--verbose] [--exclude REGEX]\n";几个参数的作用:
--dir:指定测试用例根目录。--jobs:并行度。--timeout:每个用例的超时秒数,超时直接杀掉进程并标记为“超时失败”。千万别省略这个参数——真有人因为某个用例卡在死循环里,整个回归任务挂了半天才发现。--verbose:是否打印完整日志。--exclude:排除规则,支持正则。
模块虽小,但解决了“参数和逻辑混在一起”的痛点。而且Getopt::Long支持--job=4和--job 4两种写法,对使用者非常友好。
3.2 自动发现测试用例——File::Find的递归扫描
接下来是扫描用例。这里用File::Find模块,它和find命令思路一致,递归遍历非常高效:
use File::Find; my @test_files; find(sub { return unless -f $_; my $name = $_; return unless $name =~ /\.t$/ || $name =~ /^test_.*\.pl$/; return if $exclude && $name =~ /$exclude/; push @test_files, $File::Find::name; }, $dir); @test_files = sort @test_files;注意我加了排序,这一步可千万不能丢。有人为了省事,用find扫描完了就直接遍历,结果用例执行顺序乱糟糟,后面做基线对照的时候痛苦不堪。稳定复现的顺序,是排障的基础。
3.3 运行用例并收集输出——fork + 临时文件 + 超时控制
运行用例有几个细节非常容易翻车:
- 子进程要用重定向把自己的输出写到独立的日志文件,千万别直接推到同一个
STDOUT和STDERR,否则并行模式下所有的东西都缠在一起。 - 超时检测要可靠,不能信任某个用例自己退出,必须做一个“到点就杀”的保险。
我在Linux下用了fork+alarm的方式。核心代码长这样:
use POSIX qw(:sys_wait_h); sub run_one_test { my ($test_file, $log_file, $timeout) = @_; my $pid = fork(); if (!defined $pid) { die "Cannot fork: $!"; } if ($pid == 0) { # 子进程 open(STDOUT, '>', $log_file) or die "Cannot open log file: $!"; open(STDERR, '>&', \*STDOUT); exec($^X, $test_file) or exit 127; # $^X 是当前Perl解释器路径 } # 父进程 my $timer = alarm($timeout); my $died; waitpid($pid, 0); my $status = $?; alarm(0); return ($status, $log_file); }有的解释器路径不一样,比如系统里装了多个Perl版本,直接用perl命令可能调的是错误版本。用$^X,也就是当前运行脚本的这个Perl解释器路径,它既能保证兼容性,也能避免环境变量PATH顺序导致的“串版本”问题。
3.4 日志摘要生成——解析TAP、统计状态、提取错误签名
跑完一堆用例,最关键的动作是阅读日志并产出摘要。这一步我用了解析TAP格式的思路。如果你的测试用例不是TAP格式,也可以退一步,只分析退出码,再配合正则提取错误行。
sub parse_log { my ($log_file, $status) = @_; my @errors; my $tap_pass = 0; my $tap_fail = 0; open my $fh, '<', $log_file or return (undef, ['cannot open log']); while (my $line = <$fh>) { chomp $line; if ($line =~ /^ok\b/) { $tap_pass++; } elsif ($line =~ /^not ok\b/) { $tap_fail++; push @errors, $line; } elsif ($line =~ /(?:FAILED|ERROR|Exception|Fatal|Cannot|die|assertion failed)/i) { push @errors, substr($line, 0, 160) . '...'; } } close $fh; my $exit_ok = ($status >> 8) == 0; return ($tap_fail ? 'fail' : $exit_ok ? 'pass' : 'fail', \@errors); }这里有一点要注意,用正则匹配错误行的时候,摘取的行数要控制住。我经常看到有人一股脑把匹配到的几百行全塞进摘要里,结果摘要比原文还长。截断到160个字符是个比较实用的经验值——足够看清一句话的关键,又不会刷屏。
3.5 生成可读、可留底的摘要报告
最后一步是把所有结果聚合成一张表。这里我为了可读性,生成了文本表格,顺带用颜色区分通过和失败(终端里红色和绿色,肉眼scan一屏就能看到重点)。如果不需要颜色,可以去掉ANSI转义码。
sub print_summary { my ($results) = @_; my ($pass, $fail, $skip) = (0, 0, 0); print "\n" . '=' x 72 . "\n"; printf "%-45s %-10s %-8s\n", 'TEST', 'STATUS', 'TIME'; print '-' x 72 . "\n"; for my $r (@$results) { my ($name, $status, $time_s, $errors) = @$r; my $color = $status eq 'pass' ? "\033[32m" : "\033[31m"; printf "%-45s %s%-10s\033[0m %-8.1f\n", $name, $color, $status, $time_s; if (@$errors && $status ne 'pass') { for my $e (@$errors) { print " $e\n"; } } $pass++ if $status eq 'pass'; $fail++ if $status eq 'fail'; $skip++ if $status eq 'skip'; } print '=' x 72 . "\n"; print "SUMMARY: $pass passed, $fail failed, $skip skipped\n"; print "TOTAL: ", scalar(@$results), " cases\n"; return $fail ? 1 : 0; }4. 实操记录:从零到跑完300个用例,我遇到了啥
理论上讲再多,不实跑一遍都等于零。我假设现在你已经把上面这些代码拼成了一个run_tests.pl脚本,下面我们来走一遍真实的执行流程。
4.1 准备测试用例环境
我这边测试目录长这样:
tests/ ├── test_auth.pl ├── test_user_api.t ├── test_order.t ├── test_payment.pl └── tools/ └── test_helper.pm这里有个重要细节:test_helper.pm不是测试用例,它是被其它用例引用的辅助模块,不应该被扫描执行。所以我在扫描逻辑里已经用正则做了过滤——只匹配.t后缀或test_开头的.pl文件。靠这个约定,辅助模块永远都不会被误当作测试用例执行。
4.2 串行执行一次,看看基线
先用串行方式跑:
perl run_tests.pl --dir ./tests --verbose日志片段:
test_auth.pl PASS 1.2s test_user_api.t PASS 0.8s test_order.t FAIL 5.1s assertion failed at test_order.t line 42: expected status 200, got 500 test_payment.pl PASS 2.3s SUMMARY: 3 passed, 1 failed, 0 skipped TOTAL: 4 cases这一版跑完,一个case失败,错误签名直接显示了test_order.t line 42。整个定位链条就成了:摘要 -> 出错文件 -> 出错行,不用去翻原始日志。
4.3 并行跑,验证稳定性
然后我用-j 4并行跑了一遍。注意,我这里的用例都是无状态接口测试,相互之间没有共享资源,所以可以安全并行。如果你的用例有共享数据库或写同一目录,建议先串行搞明白资源隔离,再并行。
并行跑出的结果不仅快(总耗时大约是串行的三分之一),而且输出的失败信息照样清晰。唯一要提醒的是,TAP解析要求每个用例的输出格式必须规范——并行会不会引起某些用例内部的全局状态冲突,这只有靠跑一遍才能发现。
5. 常见问题与排查技巧实录
到了“避坑”环节。这个脚本我前前后后改了四五个版本,下面是踩过的一些很有代表性的坑,建议收藏。
5.1 子进程的STDOUT/STDERR重定向影响了调试
第一版脚本,子进程在运行的时候直接继承父进程的STDOUT,日志全怼到一个终端窗口里。串行还勉强能看,一旦并行,屏幕上全是乱流。后面改成每个用例重定向到独立日志之后,整个世界清静了。
排查提醒:如果你发现某个用例的日志文件是空的,多半是子进程里的exec失败,比如chmod +x没做、脚本头#!/usr/bin/perl不对、或者解释器路径写错。这种失败返回的退出码是127,在摘要里会有体现,不要只盯着“exit code 0”这一个维度看。
5.2 超时炸弹:永不结束的测试进程
有次一个用例内部写了个while(1)死循环,没有配套的break条件。串行跑的时候,任务卡在那个用例上,谁也发现不了;加超时之后,它是被alarm信号杀掉了,但是更恶心的是,它可能在子进程里又派生了一个孙孙进程,根进程被杀不代表整个进程树都跟着撤退。
我的排查解法:第一,在子进程里设置setpgrp,把它单独划到一个进程组,kill时可以整组杀掉;第二,在超时逻辑里用kill('KILL', -$pid),把这个组一锅端。这是比较底层的进程管理技巧,但测试调度工具一旦弄不好,就跑得紧巴巴的。
5.3 日志文件无限增长,把磁盘挤爆
几百个用例跑下来,每个用例日志2MB,就是几百MB的量级;如果不定期清理,机器磁盘可以被撑到100%。我做过两个改进:
第一,只在用例失败时保留完整日志,通过的在跑完后直接截断成摘要或删除,磁盘占用一下就降下来了。第二,日志文件按日期分目录存放,配合一个简单的cron定期清理7天前的日志,实现“日志自动化生命周期管理”。
5.4 Windows下fork不可用
我一开始没考虑Windows,结果拿到一台Windows机器上去跑,fork直接不支持。习惯用Perl做工具的人,很可能主力环境就是Linux;但谁敢保证别人不换平台呢。
解决方式是写了一个检测逻辑:如果$^O是MSWin32,就改用system配合IPC::Run来做超时,或者直接降级为串行执行。原理上说,Windows下做进程管理和信号控制本来就麻烦,降级为串行+完整日志保留,虽然慢一点,但胜在稳定。
5.5 摘要里的失败签名不全,定位成本反而变高
最初实现里,错误行截断到160字符,有的行截出来就是半句话,需要一个一个点开日志才知道怎么回事。后来我改成两行策略:第一行是完整错误行的前160字符,第二行打印行号在哪儿。
本质上,摘要信息是为了快速分级——一眼看懂的直接处理,看不懂的再打开日志细看。不是所有上下文都要塞进摘要里,那会让摘要失去意义。
6. 扩展思路:从“能用”到“好用”的几种演进方向
这个脚本目前解决的是“批量跑用例并出摘要”的基础需求。但实际用起来,你大概率会遇到更多的诉求,我列几个我后续做的扩展,你可以照着演进去翻新。
6.1 失败自动重试机制
接口测试最烦的点是偶发超时或网络抖动,单跑一次流量的结论很不靠谱。我后来给脚本加了--retry N参数,对每个用例最多重试N次,只有连续N次全失败才判定为失败。每次重试失败都会单独记录,方便事后分析哪些是稳定的问题。
6.2 增量回归和基线对比
回归测试的价值在对比。于是我把结果写到一个JSON文件里,下一次跑完自动和上一次进行对比,标出“新增失败”“持续失败”“本次恢复”。这个功能对发布前的验证场景特别实用,CI里一眼就能看到趋势变化。
6.3 接入CI系统
我后来把脚本的退出码做成“有失败就返回非0”,这样就能被Jenkins、GitLab CI这些系统自动识别成构建失败。配合脚本自动生成的summary.txt,pipeline里一个步骤就能把测试报告带出来。
6.4 日志摘要生成独立的HTML报告
文本摘要自己能看够用了,但要是给团队其他人看,HTML报告直观得多。我在文本报告的基础上,又写了一版HTML报告生成器,把通过率、失败列表、耗时Top 10用表格展示,整个回归结果一目了然。技术栈就是Perl的HTML::Template,十分钟的事。
7. 写在最后:做工具,先满足自己能省时间,再考虑别人怎么用
用Perl做一套批量跑测试用例和日志摘要的工具,从写第一行代码到稳定跑完几百个用例,我自己大概花了一个周末。说实话,当时很多代码是从网上各个角落拼起来的,边跑边调试,边调整正则和超时逻辑。
总结下来,我觉得真正有价值的地方不在“脚本本身有多炫”,而在于你把自己的测试流程抽象成了有规律的输入输出:用例清单是可发现的,执行是可控的,结果是可对比的。这三个标准到位,不管你用Perl、Python还是Go,工具都好使。
最后分享一个我后来才加上的小功能:脚本支持把摘要同时追加到一个history.log文件里,每天跑完会留一行时间+通过数+失败数。跑上一两个月之后回头翻一眼这个文件,每次迭代的质量趋势一目了然,那感觉真的挺值的。
如果你手头也有一堆测试脚本正在手动跑,不妨花个把小时把上面的脚本拼起来试试。相信我,下次跑回归的时候,你会回来感谢这个周末下午的。