ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Playwright测试执行策略:从顺序、并行到分布式分片实战

Playwright测试执行策略:从顺序、并行到分布式分片实战

先说个真实场景。我去年接手一套用Playwright写的端到端测试,当时大概6个spec文件,一次性跑下来不到8分钟,谁都不觉得执行策略是个问题。等到业务迭代起来,spec涨到60多个,单机顺序跑要40多分钟,CI排队排到开发同学开始骂人。第一反应是加机器,第二反应是加retry,结果机器加了、重试加了,失败反而更随机了。

后来才意识到,真正的问题是执行策略压根没被设计过。顺序、并行、分布式,这三件事看着是同一句话里带过的名词,实际落地时是完全不同的三套逻辑,每一层都有各自的坑。这篇文章把我在这个过程中的完整思路、配置、坑都写出来,给那些用例量不大但总觉得跑得很慢、或者一开并行就满屏随机失败的团队做参考。

1. 顺序执行:慢不是问题,掩盖问题才是

很多人一听“顺序执行”就觉得落后,第一反应是把workers调大。但顺序执行在Playwright里其实是默认认知最混乱的一个模式,因为很多人根本不知道Playwright默认就不是全局顺序的。

1.1 workers参数与“默认并非顺序”的认知

Playwright Test的并发模型分两层:文件之间默认是并行执行的,单个spec文件内部的测试用例默认是按顺序执行的。你跑npx playwright test的时候,它会把所有spec文件丢进多个worker,每个worker是一个独立的Chromium进程,各跑各的文件,文件内部的test则逐个执行。

想要真正的全局串行,只有一个办法:npx playwright test --workers=1。这个参数很多人用过,但不少人以为它只是“减小并发”,实际上它会完全关闭文件间并行,让所有用例从头到尾一条路走完。

那问题来了:全局串行除了慢,到底有什么价值?我的体会是,调试价值最大。当你在排查一个偶发性失败时,并行环境下日志是错乱交织的,两个worker同时操作同一个测试环境,你根本分不清是脚本问题还是环境被另一个worker污染了。而--workers=1能给你一份干净的、时间线唯一的执行记录,失败路径一目了然。

另外,Playwright还提供了test.describe.configure({ mode: 'serial' }),它可以单独让某个describe块里的用例强制串行,同时不影响其他文件的并行。这个设计其实很聪明——你不需要为了某几个有依赖的用例牺牲整个套件的速度。

1.2 什么场景下顺序执行依然是最优解

顺序执行不是落后,它在三种场景下是最优解。

第一种是存在显式依赖的用例。比如电商支付流程:先创建订单,再触发支付回调,最后验证订单状态。理论上应该通过API造数据来解耦,但有些场景的链路本来就要求端到端完整走一遍,强行拆成互相独立的用例反而是在造假。这种时候不要硬并行,老老实实串行,或者按下依赖顺序拆成几个文件再配合test.describe.configure({ mode: 'serial' })控制。

第二种是调试阶段。我建议每个团队都保留一个npm run test:debug脚本,内容就一行:playwright test --workers=1。当并行环境下出现偶发失败,先用这个模式重跑两遍。如果串行稳定、并行必现,那90%不是脚本写得有问题,而是用例之间存在隐藏的共享状态;如果串行也偶发,那就要往网络、环境数据这类外部因素上查。

第三种是测试环境本身扛不住并发。有些团队的测试环境是共享的,数据库只有一份,用户账号只有几个。多个worker同时跑,互相踢下线、互相修改同一条数据,这种状态下并行越快死得越快。顺序执行不是逃避问题,而是给自己争取时间把测试环境改成支持隔离。

但我要泼一盆冷水:顺序执行只能掩盖状态耦合,不能消除状态耦合。如果你的用例依赖一个全局登录态、依赖某个磁带上残留的数据,你靠--workers=1把测试跑绿了,这不算解决,只是把雷埋得更深。等哪天必须并行时,这些雷会一起炸。

2. 并行执行:从worker数量到不稳定的根源

当测试套件长到顺序跑超过15分钟,你自然会想开并行。并行本身不难,难的是并行之后的一切都不再可控。

2.1 并行机制与时间估算

先理清Playwright并行的几个层级。workers控制的是同时运行的浏览器实例数量,每个worker一次处理一个文件,文件内默认串行。如果你在配置文件里开了fullyParallel: true,那么单个文件内部的用例也会被拆到不同worker里并行执行。

这里要特别注意:文件是并行调度的最小单元。也就是说,你有一个跑了20分钟的巨型spec文件,就算整体开10个worker,这个文件内部不拆,它照样要占掉一个worker整整20分钟,其他worker跑完自己的任务后只能干等着。对时间估算有个经验公式:

期望总耗时约等于最慢单个文件耗时 × 向上取整(spec文件总数 ÷ worker数)

举个例子:你有50个文件,最慢的一个跑12分钟,开4个worker,理想情况大约是12分钟 × 13 = 156分钟?不对,这里公式很容易误解,更准确的说法是:如果文件耗时比较均匀,总耗时约等于平均文件耗时 × 文件总数 ÷ worker数;但如果存在一个长尾文件,它就会成为整个执行时间的下限。很多人并行之后没变快多少,就是因为有一个文件占用了不成比例的时间。

在配置层面,我建议明确指定workers,不要依赖默认值。默认值在不同环境下差异很大,本地CPU核心多可能自动给你开十几个worker,CI上又可能直接降级成1。显式控制可以保证本地和CI行为一致:

npx playwright test --workers=4

也可以在 playwright.config.ts 里写死:

export default defineConfig({ workers: process.env.CI ? 4 : undefined, });

另外,worker数量不是越大越好。每个worker都是一个完整的Chromium进程,内存消耗通常在300MB到1GB之间。2核4G的云上runner开8个worker,结果就是浏览器tab疯狂被杀,失败率飙升。我个人的经验是:每个worker至少预留512MB可用内存,CPU核数不够时宁肯少开一点。

2.2 并行之后常见的不稳定因素清单

并行开启后,最让人崩溃的不是速度,而是那些“单独跑必过、一起跑随机挂”的问题。我整理了在团队里排查过最多的一批原因:

端口冲突排第一。很多项目测试时需要起本地服务,如果脚本里直接await page.goto('http://localhost:3000')之类写死了端口,一旦一个共享服务被多个worker重复启动,或者两个用例同时争抢同一个端口,失败就是随机的。解决办法是让服务监听0获得动态端口,然后通过环境变量传给测试;Playwright配置里的webServer其实帮你处理了单实例问题,但要小心它只启一次,不是每个worker一份。

共享目录和临时文件是第二大类。多个worker同时读写同一个临时目录、同一个导入导出文件、同一个本地缓存文件时,互相覆盖是家常便饭。这就好比几个人同时编辑同一份Word文档,最后存盘的人决定了所有人的命运。处理办法是让每个worker拥有独立的临时目录,尽量在用例结束前自清理。

第三类是用例本身对页面动态环境不健壮。很多脚本依赖动态iframe挂载、滚动加载、弹窗事件监听,这些用例在单机单worker下勉强能跑,一旦并行,CPU调度被打乱,资源加载时序变了,本来勉强等到的元素现在等不到了。这种不稳定性会被执行策略放大。所以,脚本里用了大量page.on监听事件、依赖滚动触发加载的地方,都要审视一下:选择器是不是足够稳?有没有显式等待关键节点?这一层练不好,后面做分布式只会更痛苦。

给人留下深刻印象的还是数据隔离问题。登录态、购物车数据、库存数据,如果测试环境只有一套,并行就是拿命换时间。这种情况该做的是环境治理,而不是调整执行策略。

排查这类问题有一个我很依赖的方法:把并发参数拉到--workers=1重跑。如果串行稳定、并行必现,说明是共享状态问题;如果串行也随机失败,说明是脚本自身稳定性问题。这个二分法是定位并行失败的钥匙,比瞎调时间等待高效得多。

3. 分布式测试:从shard到多机的第一道坎

当你发现即使开了并行,单台机器的CPU、内存也已经到极限,下一个自然想到的就是分布式。Playwright原生支持shard机制,这是进入分布式测试的最简单路径。

3.1 用矩阵分片跑起来

所谓shard,就是把整个测试套件切分成若干份,每份喂给一个独立的进程或机器。Playwright的语法非常直观:

npx playwright test --shard=1/4 npx playwright test --shard=2/4 npx playwright test --shard=3/4 npx playwright test --shard=4/4

你可以在CI上开4个平行job,每个job分别跑上面的一条命令。我在GitHub Actions里的配置大概长这样:

strategy: matrix: shard: [1, 2, 3, 4] steps: - run: npx playwright test --shard=${{ matrix.shard }}/4

这个方案的好处是,几乎零成本,CI平台本身就帮你管好了并行job的调度和日志归集。坏处也很明显——它不理解你的测试耗时分布,只是按文件做尽量均匀的分片。

这里必须强调一个shard的边界:Playwright的shard切分粒度是文件,不是用例。它不能把一个spec拆成两半分配到不同机器上。所以如果你的套件里有一个特别大的文件,它跑多久,整个分布式执行就至少跑多久,其他机器跑完都在等它。

3.2 多机环境带来的状态与产物问题

多机分布式带来的第一个问题是环境一致性。每台runner都是一个从零构建的机器,都要重新装Node依赖、下载Chromium。这里有一个很实在的提速技巧:把浏览器缓存起来。Playwright的浏览器默认安装在~/.cache/ms-playwright之类的目录下,把这个目录做成CI的缓存,可以避免每台机器都从那几个大文件下载一遍。你还可以在构建阶段用PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1跳过下载,后续再统一解压离线包,能节省大量时间。

第二个问题是测试环境本身的并发能力。单机并行时,也就几个worker同时打测试环境;上了多机分片,瞬间可能变成几十上百个worker同时操作。如果测试环境只有一份数据库和几个账号,你等于是用4台机器制造了一个更大型的并发崩溃现场。所以,做分布式之前,务必确认测试环境支持多租户隔离、数据可重置、账号可动态创建。否则shard越多,失败率越高。

第三个问题是产物归集。每台机器跑完都有自己的test-results目录和trace。如果所有机器都往同一个artifacts目录传同名文件,后传的会覆盖先传的。我的习惯是在上传前用runId给目录重命名,比如test-results-${{ matrix.shard }}/,最后汇总时用一个job下载所有分片产物。

第四点是失败定位难度的上升。以前是本机日志、本机trace,一眼能找到败因;现在要找到是哪台机器跑的哪个分片,再去对应的artifact里下载trace,链路长了不止一倍。在这个阶段,一个可靠、可合并的报告比什么都重要,这会直接引出后面第5章的内容。

4. 让分布式更均衡:基于历史耗时的自定义分片

如果你跑了一阵子固定shard,大概率会看到一种现象:1号机器跑了20分钟,2号机器跑了8分钟,3号机器跑了6分钟,4号机器跑了4分钟。看起来开了4台机器,实际效率只有不到一半。这就是文件粒度切分带来的天然不均衡。

4.1 固定shard为什么总会倾斜

固定的--shard=1/4走的是Playwright内置的均衡策略,它会尽量让每个分片的文件数量接近。问题是,文件数量接近不代表执行时间接近。随着业务迭代,总会有那么一两个大文件,或者一个被反复retry的慢文件(比如里面加载了多个动态页面),它们会让整个分片的时间失衡。

我之前遇到最夸张的一次,某个spec因为页面资源加载慢,单独要跑20分钟,而其他文件平均只有1分钟。用固定4分片,等于3台机器陪跑19分钟。这种情况下,不是机器不够,是分片策略有问题。

4.2 一个能落地的贪心分片脚本

要解决这个问题,我采用了一个很朴素但有效的方案:用上一次完整执行的历史耗时做权重,对文件做贪心分片。核心思想是把文件按耗时从大到小排序,每轮把当前文件分配给当前累计耗时最少的分片。这其实是多机调度里经典的LPT算法。

第一步,在CI或本地跑一次完整测试,用Playwright的JSON reporter生成测试报告:

export default defineConfig({ reporter: [ ['json', { outputFile: 'last-run.json' }], ['html', { outputFolder: 'playwright-report' }], ], });

第二步,写一个解析报告并生成分片列表的脚本。我把一个简化版本放在团队里,核心逻辑只有几十行:

import json import subprocess import argparse def load_file_durations(report_path): with open(report_path, encoding="utf-8") as f: report = json.load(f) durations = {} for suite in report.get("suites", []): for spec in suite.get("specs", []): file_path = spec["file"] # 取文件内所有用例通过的耗时之和作为该文件耗时的近似 total = sum(tc.get("duration", 0) for tc in spec.get("tests", []) if tc.get("ok")) durations[file_path] = durations.get(file_path, 0) + total return durations def greedy_distribute(durations, shard_count): shards = [[] for _ in range(shard_count)] costs = [0.0] * shard_count for file_path, duration in sorted(durations.items(), key=lambda x: -x[1]): target = min(range(shard_count), key=lambda i: costs[i]) shards[target].append(file_path) costs[target] += duration return shards, costs def main(): parser = argparse.ArgumentParser() parser.add_argument("--report", required=True) parser.add_argument("--shards", type=int, required=True) parser.add_argument("--shard-id", type=int, required=True) parser.add_argument("--workers", type=int, default=2) args = parser.parse_args() durations = load_file_durations(args.report) shards, costs = greedy_distribute(durations, args.shards) target = shards[args.shard_id - 1] print(f"shard {args.shard_id} files: {len(target)}, estimated {costs[args.shard_id - 1]:.1f}s") subprocess.run(["npx", "playwright", "test", *target, "--workers", str(args.workers)], check=True) if __name__ == "__main__": main()

注意这里有个很容易踩的坑:子进程里的命令会把目标文件列表传给Playwright,而Playwright拿到这些文件之后,如果没有特别处理,默认还会启动自己的workers并行。这是对的,因为分片和单机并行是两个层次,分片是粗粒度跨机器的并行,单机workers是细粒度进程内并行。两者配合使用才能把资源吃满。

还有一个细节:历史耗时数据会过期。业务代码一改,页面性能一变,原先的权重就失真了。所以我建议CI里每次full run之后都更新last-run.json,下次执行直接引用最新的基线。

5. 分布式后的合并、重试与结果可信度

分布式测试跑起来了,速度上去了,但如果你不能快速、可靠地判断一次跑完到底哪些用例挂了、挂在哪台机器上,那这种提速是虚假的。这是分布式测试真正考验工程能力的地方。

5.1 blob报告合并与资源路径坑

Playwright原生提供了blob reporter,专门解决多机报告合并问题。在配置文件里加上它:

export default defineConfig({ reporter: [ ['blob', { outputDir: 'blob-report', outputFile: `report-${process.env.SHARD_ID ?? 'local'}.zip` }], ['html', { outputFolder: 'playwright-report' }], ], });

每台机器跑完,把blob-report目录当artifact上传,然后由一个汇总job执行合并:

npx playwright merge-reports --reporter=html ./blob-report

这个命令会把所有分片的blob数据合并成一个完整HTML报告,包含每一条用例的路径、状态、截图、trace。这是分布式测试能落地的关键能力——没有合并报告,你永远只能看单机局部结论。

但合并报告有几个坑需要注意。第一个是资源路径。每个分片机器上生成的截图、trace是相对于自己工作目录的,合并后如果路径对不上,点击trace只会得到404。我的解决办法是确保所有分片在同一个相对路径结构下运行,合并命令的工作目录要与跑测试时保持一致。第二个坑是报告里的时间顺序。合并后的报告按分片分组,不能按原执行时间线还原,所以不要试图从合并报告里分析“到底是谁先污染了数据”,这条线索在分布式环境下基本查不出来。

还有一个我在做的事是,把合并后的JSON喂给脚本做失败原因聚合,甚至接给编程助手这类工具辅助排查。但你要有预期:合并后的数据结构是分片聚合的,不是按时间线排列的。解析时不要依赖用例之间的顺序关系,否则很容易被误导。

5.2 retries不是越多越好

分布式环境下,retries的行为容易被误读。Playwright的retry机制是,在同一个分片内对失败的用例重新执行。它不会把失败用例“迁移”到另一台机器上重跑。也就是说,如果这台分片机器刚好网络抖动,retry多半还会在这台机器上继续失败。

我见过很多团队把retries从0一路加到3,失败率没降多少,执行时间倒是翻倍了。这里有一个经验判断:retry一次之后如果失败率没有显著下降,你遇到的问题就不属于“偶发网络”或“资源抢占”,而是用例本身依赖了不该依赖的外部状态。这时候加retry只是把问题从“随机失败”变成“随机失败但多浪费几分钟”。

合适的做法是,把retries控制在最多1次,然后认真对待仍然失败的用例。用Playwright的--last-failed选项可以在下次提交前快速重跑上次的失败用例,这个对PR重刷非常实用:

npx playwright test --last-failed

它只跑上次失败过的用例,适合在代码微调后快速验证。但要注意,在分布式场景下,--last-failed要在能访问上次报告的环境里运行,而且它只对当前机器可见的失败记录生效,最可靠的做法还是在汇总job合并完报告之后,从合并报告里提取失败列表再定向重跑。

关于trace,我强烈建议在分布式运行中开启trace保留on-first-retry,这样不仅失败时有现场可查,retry后依然失败的用例会保留两份现场,对比着看能定位很多“时好时坏”的问题。这个成本不高,但很值。

6. 从单机到集群的选型取舍,我的落地实践

写到这里,你应该已经看出来,执行策略不是越高级越好,而是匹配当前团队测试套件的规模和稳定程度。我把自己的选型思路做一个近似于决策框架的总结。

6.1 判断标准与成本对比

我倾向于用三个数字做判断:总时长、单文件最长耗时、失败重跑率。

  • 总时长在5分钟内,顺序执行完全够用,不要折腾。这个阶段的收益应该放在把用例写稳上。
  • 总时长在5到20分钟,优先开并行,把workers调到和runner的CPU核数匹配。同时开始关注用例之间的共享状态问题。
  • 总时长超过20分钟,或者你发现CI排队已经影响研发节奏,再考虑分布式shard。但上分布式之前,务必先解决环境并发能力和报告合并问题。

从成本对比来看,顺序执行的成本最低,但失败定位也最简单;并行的成本主要是排查状态耦合的时间,收获是线性提速;分布式的成本最高,涉及多机环境、产物归集、报告合并、数据隔离,收获是超越单机资源上限的扩容能力。不要因为“别人都在用分布式”就上分布式,如果你的套件只有10分钟,4台机器的分布式反而会引入一堆新问题,得不偿失。

6.2 我在团队里的三层执行策略

目前我在团队里跑的是三层策略,按触发时机区分。

第一层是PR里的快速冒烟集。只跑核心主路径,用--workers=4并行,控制在5分钟以内。目标是让开发在提交代码后快速得到“主流程没有坏”的反馈。第二层是合入主干后的全量回归。用分布式shard跑完整套件,按历史耗时做自定义分片,每个分片内再开2个worker,目标是覆盖所有改动的回归面。第三层是一个保留的“慢速调试模式”:--workers=1的全量串行。它只在出现难解的偶发失败时使用,用来还原干净的执行环境,排除共享状态干扰。

我最后想分享的一个体会是:执行策略优化的目标,不是把CI时间压到无限接近零,而是把“失败定位时间”压到最短。增加并行机器能节省的是测试执行时间,但如果一次失败需要花半小时去排查是哪台机器、哪个文件、哪个共享变量造成的,那整体研发效率不升反降。所以每往下一层,都要先确认报告可合并、trace可下载、状态可隔离,这三件事没做好之前,宁可慢一点、稳一点。

分布式测试真正舒服的状态,不是跑得飞快,而是跑完以后你能在几分钟内指着报告说:这个失败是脚本问题,那个失败是环境问题,用例本身没问题。到这一步,策略才算是真正落地了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进