测试这件事,很多时候卡脖子的不是用例写得慢,而是跑得慢。一个全量回归集几百条用例,串行跑下来几个小时,CI队列排得人心浮气躁;改成并行又经常遇到用例互相打架,今天红几条明天绿几条,排查半天发现是共享数据被串了。Playwright作为目前自动化测试的主力工具之一,它的执行策略其实有很成熟的玩法:顺序执行保稳定,并行执行换时间,分布式执行跨机器摊压力。这篇就把我在实际项目里折腾过的执行策略完整拆开讲,包括底层逻辑、配置写法、适用场景和踩过的坑,希望能让正在被测试耗时和稳定性折磨的人少走点弯路。
我默认读这篇文章的人已经对Playwright的基本用法有概念,比如会写用例、知道page对象、会用pytest或者Node Test Runner。如果没有这些基础也不影响,只要你有自动化测试需求,顺序、并行、分布式这三个概念以及它们背后的权衡逻辑,在任何测试框架里都是相通的。
1. 为什么要关心执行策略:顺序执行是基础,但效率瓶颈很明显
1.1 顺序执行的默认行为与适用场景
先用最朴素的方式说结论:Playwright默认的执行方式就是顺序执行。不管你是用npx playwright test跑Node Test Runner,还是用pytest集成跑Python用例,默认状态下测试用例会按顺序一条条往下执行。这模拟的就是一个人坐在电脑前,打开浏览器,操作完一个场景再操作下一个场景的物理限制。
顺序执行的价值在于确定性,这一点怎么强调都不过分。用例之间如果存在状态依赖,比如A用例创建了一笔订单,B用例去查询这笔订单,那A必须跑在B前面。这种强依赖场景下你搞并行就是自找麻烦。我在项目里专门划分了一批冒烟测试,走的就是纯顺序执行:登录、权限校验、核心链路走完,全部串在一起。这条链路是给开发提交代码后快速验证用的,跑得慢一点没关系,能稳定发现问题才是第一位。
顺序执行的代码形态也很简单。用pytest跑的时候,什么额外配置都不用加,默认逐个执行。用Playwright自带的test runner跑的时候,配置文件里不设置fullyParallel,默认也是串行。这种"零配置"特性让顺序执行成为了门槛最低、最不容易出错的起点。很多团队一开始做自动化测试,就应该是从这个起点开始的。
但顺序执行的问题是数学层面的:N条用例,每条平均耗时M秒,总耗时就是N乘以M。当用例库膨胀到几百条甚至上千条的时候,这个线性增长是完全不可持续的。我见过一个团队的上千条UI用例全量回归要跑6个多小时,这已经不是效率问题了,是致命问题——开发早上提交的代码,下午下班前才拿到测试结果,问题定位成本高得离谱。
1.2 一条用例的时间都花在哪里
要理解为什么执行策略值得花心思研究,先把一条Playwright用例的时间消耗拆解开来。一条典型的UI用例时间大致分成四块:
- 启动浏览器实例,Node进程拉起Chromium,这一步基础耗时约1到2秒,快不了;
- 页面加载、接口响应、DOM渲染,网络和前端性能决定的等待时间;
- 用户操作过程中的真实等待,比如点击后的交互反馈、模态框弹出、滚动加载,每一步都需要等待稳定;
- 断言和清理,包括截图、追踪文件生成、浏览器上下文销毁。
第一和第四块是固定开销,不管测试什么功能都跑不掉。真正可以压缩的是第二部分和第三部分,但这是前端性能和产品交互层面的优化,测试框架本身能做的有限。所以你会发现一个事实:并行不是让你每条用例跑得更快,而是让多条用例在同一段时间窗口内同时跑完,把单位时间的吞吐量提上去。这和工厂流水线的逻辑一样——单个工人装配一台机器的时间没法缩短,但多开几条流水线,产量就上去了。
这也就是为什么执行策略的优化空间巨大。假设一条用例平均耗时30秒,800条用例串行就是6.6个小时。如果并行规模拉满到8个worker,理论上可以压缩到50分钟左右,这个差距是体验级的。但代价是什么?代价是你会遇到各种顺序执行时根本不存在的问题:测试数据冲突、端口资源抢占、数据库唯一约束爆炸、共享目录互相覆盖。工程上的一切优化都是取舍,并行测试把时间问题转换成了隔离问题。
2. 并行执行:用资源换时间,先搞清楚Playwright并行的底层机制
2.1 并行不是"多开几个浏览器"这么简单
很多从Selenium转过来的人有个误区:以为Playwright并行就是多开几个浏览器窗口。实际上Playwright的并行粒度和隔离模型比Selenium时代的WebDriver方案严谨得多。这里要引入一个核心概念:worker。
在Playwright的测试执行体系里,worker就是独立的工作进程。每个worker是操作系统层面的独立进程,拥有独立的Python或Node运行时环境,独立加载配置文件的最初状态,然后在这个隔离环境里创建自己的浏览器实例。也就是说,8个worker就是8个进程,每个进程拉起自己的Chromium,每个Chromium里跑自己的测试用例。它们之间在进程层面就是完全隔离的,连全局变量都不共享。这才是并行的安全边界。
更关键的是Playwright引入了浏览器上下文(browser context)这个概念。一个浏览器实例可以创建多个独立的context,每个context相当于一个独立的"隐身窗口会话"——独立的Cookie、独立的localStorage、独立的缓存。这个隔离性比Selenium时代的多个driver实例之间互相污染要干净得多。所以用Playwright做并行测试,只要你给每个测试用例创建独立的context,它们之间几乎不存在浏览器层面的互相干扰。
那并行数量怎么控制?两条路径:第一条是配置文件里设workers字段;第二条是命令行传参--workers=4。如果不显式设置,Playwright的默认workers数量是CPU核心数的一半。这是一个很保守的默认值,核心原因我在后面讲资源问题时细说。
2.2 并行模式与代码示例
在Playwright自带的test runner里,并行的默认配置是这样的:
// playwright.config.ts import { defineConfig } from '@playwright/test'; export default defineConfig({ workers: 4, // 并发worker数量,按机器CPU核数和内存来定 fullyParallel: true, // 让同一个文件内的测试用例也并行执行 });fullyParallel这个参数值得单独讲。在旧版本Playwright中,多个测试文件之间是可以并行的,但同一个文件内的测试用例默认按顺序跑。这是从JUnit时代就传承下来的约定:文件内共享一些前置状态,串行更安全。但fullyParallel: true打破了这个限制,让同一个文件内的用例也可以并行执行。前提是你得保证这些用例完全独立。
用pytest跑的时候逻辑不一样,因为pytest本身天然是串行的。Playwright官方只是把page等fixture注入给pytest,执行调度权还是在pytest手里。所以pytest场景下的并行通常走pytest-xdist插件:
pytest tests/ -n auto-n auto意思是根据CPU核心数自动决定并行worker数。也可以手动指定:-n 4就是4个并行worker。xdist的工作原理和Playwright的worker类似,每个worker也是一个独立的Python进程,跑一部分测试用例,最后汇总结果。需要注意一点:xdist的并行和Playwright自身的worker并行是两个层面的东西,完全不冲突,但在同一套测试工程里同时使用需要慎重,容易造成资源叠加失控。
2.3 并行时的三个经典翻车现场
并行执行听起来美好,但实际应用时我遇到过几种非常典型的翻车情况,这里给后面的实践者提前排雷:
第一个是共享测试数据冲突。最常见的是注册类用例:两条用例同时向数据库插入同一条用户记录,如果有唯一索引,必有一条失败。或者两条用例并行执行时都去修改同一个订单状态,后读先写导致断言结果随机。这叫做测试间资源共享违背了并行隔离的前提。解决办法是每个用例准备独立的数据,哪怕用时间戳或者UUID后缀生成随机用户名,也比复用固定数据要可靠得多。
第二个是静态资源互相覆盖。有一个项目里我把截图输出到同一个目录,并行执行时两个worker同时往同一个文件名写截图,结果截图文件互相覆盖,严重时直接报文件占用错误。后来我把输出目录按照worker名或者测试用例名做了动态分离,世界清净了。配置里outputDir可以写成动态格式,比如基于测试标题生成子目录。
第三个是资源耗尽导致浏览器崩溃。并行worker数量不是越多越好。每个Chromium实例大概要占用几百MB内存,8个worker同时起就是好几个GB。我遇到过在只有8GB内存的CI机器上跑--workers=8,跑到一半机器内存耗尽,worker被操作系统直接杀掉,测试结果一片红。这个问题的本质是:并行执行把瓶颈从CPU转移到了内存。保守做法是worker数不超过逻辑核心数的一半,激进做法是看内存总量来定,大致内存GB减2再除以每个浏览器占用的内存。
| 资源维度 | 默认策略 | 优化建议 |
|---|---|---|
| 独立进程隔离 | 每个Worker独立进程 | 保证测试数据在业务层面也互相独立 |
| worker数量 | CPU核心数的一半 | 结合内存上限调整,宁可少不可多 |
| 同文件内用例 | 串行(未开fullyParallel) | 确认无状态依赖后再开fullyParallel |
| 输出与临时文件 | 全局目录 | 按worker或用例动态切分目录 |
3. 分布式测试:把测试拆到多台机器,Playwright的分片机制
3.1 分布式和并行的本质区别
并行和分布式这两个词经常被混着说,但在测试执行领域它们有非常明确的界限。并行的前提是一台机器上有多个CPU核心,多个worker在同一个操作系统里共享资源。而分布式的核心特征是跨机器:测试用例被切分成若干片,每一片交给一台独立的机器去跑,机器之间只有结果汇总关系,没有资源竞争。
什么时候需要分布式?最简单的判断标准:单机并行已经压榨到极限,但还是满足不了时间要求。比如800条用例,每条的浏览器操作都重,单机8个worker跑接近极限了还是要40分钟,你希望压缩到10分钟,那只能上4台机器,每台跑200条,可能12分钟就搞定。另一个场景是跨浏览器矩阵:同一套用例要跑Chromium、Firefox、WebKit三个浏览器,哪怕单机也支持串行或并行切换浏览器,但为了缩短总体时间,更好的方案是三台机器分别跑一种浏览器,互不干扰。
Playwright官方对分布式的实现方式非常干脆:sharding,分片。它不是自己发明一套分布式调度协议,而是提供一个很简单的切分机制:把全部测试用例按序号切分成N片,每条命令只跑其中一片。这有点像是把一副扑克牌按顺序分成整数份,每台机器拿其中一份去跑。只要每台机器拿到的片拼接起来正好是全部用例,结果汇总起来就是完整报告。
3.2 Playwright分片怎么用
分片的命令行用法是在playwright test后面加--shard参数:
# 把测试分为4片,运行第2片 npx playwright test --shard=2/4这里的语法是分数形式:2/4表示总分片数4,当前跑第2片。在pytest体系里,Playwright分片这个原生能力用不上,但可以借助兼容方案,比如pytest自带的--dist loadgroup配合自定义分组,或者干脆用pytest-xdist按执行计划均匀切分。
在CI里做分布式的标准做法是把分片命令放进矩阵任务配置。这里以最常用的GitHub Actions为例:
name: Playwright Tests on: [push] jobs: e2e-tests: strategy: matrix: shard: [1/4, 2/4, 3/4, 4/4] runs-on: ubuntu-latest steps: - name: 拉取代码 run: git checkout ${{ github.ref }} - name: 安装依赖 run: npm ci - name: 运行分片e2e测试 run: npx playwright test --shard=${{ matrix.shard }}这段配置跑起来之后,CI平台会自动拉起4个独立虚拟机,每个虚拟机都完整安装一遍依赖和浏览器,然后各自跑属于自己的那1/4用例。最后你从CI界面看到的是一次矩阵构建下的4个任务汇总,整体效率和单机并行的差别在一倍以上。
关于分片数量怎么定,我的经验是要结合执行时间分布来考虑,尽量不要盲目切太多片。因为Playwright的分片是顺序切分,不是动态负载均衡。如果测试用例的执行时间方差特别大,比如第3片里恰好包含了所有重用例,而第1片全是轻量用例,就会出现明显的长尾效应:三台机器早就跑完了,等最后一台等了20分钟。我的做法是先按历史执行记录把重用例打散分布在文件列表层面,或者在用例层面按业务模块分目录,让每片的时间相对均衡。
3.3 分片之外的分布式补充方案
除了官方分片,还有两个思路值得提一下。一个是按标签切分,这在用例数量大、业务模块清晰的团队里很好用。通过--grep @login之类的tag标记,把登录相关用例放一片,订单相关用例放另一片,每台机器跑一个业务域。这种切分的好处是业务语义清晰,短板是容易切不均匀。
另一个思路是用独立构建节点并行跑不同的测试套件目录。比如tests目录下有regression和functional两个子目录,直接在CI里定义两个job,分别跑不同目录。这其实是最早、最朴素的分布式:不在同一套测试命令里做切分,而是在CI层面按目录分发。它的优点是组织零成本,每个人改自己的目录就行,缺点是切分粒度太粗,无法精细调整负载。
分片也好,目录分发也好,本质上都是"把大任务拆小、喂给多台机器"。每种方式都有自己的适配场景。我自己用得最多的还是官方--shard,因为它是官方设计,跟测试报告、结果汇总系统配合完好,不需要额外维护调度代码。
4. 顺序、并行、分布式如何组合:一套可落地的选择矩阵
4.1 执行策略的选择矩阵
策略无所谓绝对的好与坏,只有合不合适的区别。我给执行策略选择列过一个简单的判断矩阵,这里直接分享出来。判断的依据是三件事:时间要求、稳定性要求、资源规模。
| 场景 | 推荐执行策略 | 核心理由 |
|---|---|---|
| 冒烟测试、核心链路验证 | 顺序执行 | 稳定性压倒一切,结果可预期 |
| 全量回归(单机) | 并行执行 | 压缩时间,提升吞吐量 |
| 跨浏览器矩阵 | 分布式分片执行 | 每种浏览器独立机器,互不拖累 |
| 用例强依赖(A前置给B) | 串行片段内执行 | 保依赖顺序,避免竞态 |
| 大规模用例库(上千条) | 分布式 + 并行组合 | 先分片再片内并行,双重加速 |
组合用法才是生产环境里的常态。我见过的最好的方案是分级分层:提交代码时跑冒烟测试,纯顺序执行,10分钟出结果;合并代码前跑全量回归,分配4台机器做分布式分片,每台机器再开4到6个worker做片内并行;跨浏览器兼容测试单独建一个任务,只在晚上定时跑。
4.2 用test.describe.configure把混跑策略写进代码
组合策略的执行离不开代码层面的显式控制。Playwright给了一套很实用的API,允许在同一个文件里对不同的测试组设置不同的执行模式。这个能力是用test.describe.configure实现的:
import { test, expect } from '@playwright/test'; // 这个块里的用例强制串行,适合有强依赖的流程 test.describe.configure({ mode: 'serial' }); test.describe('用户下单全流程', () => { test('创建订单', async ({ page }) => { // ... }); test('支付订单', async ({ page }) => { // ... }); }); // 这个块里的用例并行 test.describe.configure({ mode: 'parallel' }); test.describe('独立功能点巡检', () => { test('页面标题正确', async ({ page }) => { // ... }); test('导航菜单可用', async ({ page }) => { // ... }); });mode: 'serial'表示这个分组内的用例按顺序执行,并且如果其中一条失败,后续用例会被跳过,避免无意义的连锁失败。这个特性和实际场景很贴合:下单失败后的支付用例本来就是无效的。mode: 'parallel'则是明确要求组内用例并行。这套配置让执行策略的表达颗粒度从"整个项目"细化到了"一个describe块",灵活性一下子高了很多。
pytest场景下没有describe.configure这个概念,但pytest有自然的标记体系可以用。对需要串行的用例打标签,结合-k或--dist loadscope参数在命令层控制执行方式。实际效果接近,只是表达方式更偏向命令行而不是代码装饰器。
4.3 执行策略之外的稳定化配套
无论选什么执行策略,稳定性都是底线。我这里把自己项目中验证过的四个配套手段列出来,这些手段直接决定你的并行策略能不能落地。缺一个,并行跑起来就是灾难。
第一个是登录态管理。UI自动化里最常见的问题就是登录态。并行执行时每个worker都是独立进程,登录操作会被重复执行多次,既浪费时间又容易触发风控策略。我的方案是利用Playwright的storageState机制:用全局setup先登录一次,把登录后的上下文状态保存成一个JSON文件,然后每个worker启动时直接加载这个文件,跳过登录步骤。这个方案能把全量回归里的登录时间压缩到趋近于零。
// playwright.config.ts import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ globalSetup: './global-setup', // 执行一次全局前置,登录并存储状态 use: { storageState: 'login-state.json', // 每个worker从文件加载登录态 }, });第二个是测试数据生命周期管理。并行执行时,每个用例必须使用独立的测试数据。这意味着数据准备阶段要能动态生成唯一数据,数据清理阶段要能精准清理自己产生的数据,绝不能全局删除。我见过最惨的事故是并行跑回归时,某条用例的teardown里执行了DELETE FROM orders,把所有测试数据全清了,其他正在跑的用例当场崩盘。数据隔离这件事,必须在一开始设计用例时就刻进基因里。
第三个是超时配置调优。并行环境下系统资源紧张,页面加载时间会被拉长。如果不给每个等待操作设置合理的超时时间,用例会因为偶发的慢启动而误报失败。Playwright里全局可以设一个expect超时,单个操作又可以单独覆盖。我的一般经验是:正常网络环境下expect超时设5秒,CI机器上设10到15秒,action超时默认是30秒,基本不调。给等待留足缓冲,但别设到无限长,因为超时机制本来就是防挂死用的。
第四个是结果可观测性。并行和分布式会让排查问题的复杂度直线上升。同一时刻有N条用例在跑,你看到一条报错,怎么知道它对应哪个请求、哪段网络日志、哪次DOM快照?Playwright的trace功能这时候就是救命稻草。配置里开启trace保留:
export default defineConfig({ use: { trace: 'retain-on-failure', // 失败时保留追踪文件 }, });这样每条失败用例都会自动生成一个包含网络请求、页面快照、控制台日志和时间线的zip包,点开就能回放整个执行过程。用这个配合并行跑,报错排查效率能提升好几倍。
4.4 分片大小与执行时间均衡的实操经验
前面我说过playwright分片是顺序切分,不是负载均衡,所以执行时间的均衡性需要额外处理。处理手段有两个方向:
第一是合理安排测试文件目录结构。Playwright的分片粒度是测试文件级别的(在fullyParallel开启前)或测试用例级别的。如果文件目录按测试耗时分布不均衡,分片结果就容易长尾。我的做法是在项目里把重用例单独放到tests/heavy/目录,轻用例放到tests/light/,分片时通过--grep参数把轻重分开跑。
第二是利用CI上的历史测试时间统计反向调整。Playwright的HTML Report里自带每个文件的运行时长统计,跑过几轮之后你就能识别出哪些文件是重量级的。对重量级文件,可以在配置里用testMatch把它单独归类或者降低它的并行度。这些小调整属于锦上添花,但对全量测试的总体耗时影响不小。
5. 常见问题与排查技巧实录
5.1 并行后大量超时:先看资源,再调配置
并行执行最常见的现象就是以前串行时从不超时的用例,并行后开始频繁超时。我排查过多次,原因集中在三类。第一类是CPU过载,worker数量开太多,机器在进程调度上大量内耗,每一条用例都被拖慢。这种情况直接降低workers数量就能缓解。第二类是内存不足,系统开始用swap空间换页,I/O阻塞导致页面加载超时,这种情况要减少worker并同步调低浏览器并发数。第三类是网络IO瓶颈,尤其是云上CI机器的共享带宽被占满,所有worker都在抢同一个下载链路。
排查方法不复杂,一条命令就能看资源:
npx playwright test --workers=2 --timeout=120000先降并发跑一轮,如果超时消失,那基本确认是资源分配问题。再逐步提高worker数,找到当前机器能承载的临界值。这个临界值就是你这个项目在这个CI环境下的稳定运行worker数。记住这个值,写进配置里写死,别交给Playwright自动判断,自动判断永远是保守的。
5.2 测试之间相互污染:排查共享状态的三个入口
并行跑出一堆诡异的随机失败时,本质上就是有共享状态在作祟。排查共享状态我习惯按三个入口逐个检查:
第一个入口是浏览器上下文之外的内外部配置文件。比如用例里读了同一个临时文件、同一个环境变量、同一个本地端口。哪怕是读取操作,如果另一个用例在并行中修改了它,你读到的是被篡改后的值。对策是每个用例尽量只依赖自己创建的资源。
第二个入口是数据库共享数据。比如两条用例用了同一个手机号注册,后跑的用例被唯一索引拒绝。对策是数据生成全部走动态唯一化:时间戳、UUID、随机数任选一种,拼到数据末尾。这个习惯一旦养成,能避开非常多的雷。
第三个入口是全局前置脚本的副作用。globalSetup和全局fixture如果在其中修改了共享状态,各worker启动时读到的初始状态就可能不一致。比如globalSetup里删除了某个缓存目录,而另一个worker正好在用这个目录,瞬间崩掉。对策是全局脚本只做"追加和无害化"操作,避免删除和重置正在被使用的资源。
5.3 调试并行问题的三个利器
并行失败用例的调试比串行复杂一个维度,因为复现难度大。我固定的调试三板斧如下,针对性和效率都经过了几轮实战检验:
第一板斧是--debug模式单条跑。并行跑失败,先切回串行,用失败用例的标题拉起调试:
npx playwright test --debug -g "失败用例的标题关键词"Playwright的debug模式会打开Inspector工具,一步步回放执行过程,看清楚到底哪个步骤和预期不符。这个模式天然是串行的,不存在并行干扰。
第二板斧是trace文件回放。配置里开启trace后,每个失败用例的zip包里都有完整执行录像。在浏览器里打开trace文件,可以逐帧查看页面截图、网络请求、控制台错误。这个功能比debug还要省事一点,因为不需要重新跑一遍用例,直接分析历史记录即可。
第三板斧是定向观察网络请求。并行环境下很多失败本质上是接口响应与预期不符:请求发到了旧环境、被限流、或者依赖的mock服务没起来。用page.on('request'/'response')在用例里打日志,或者开启Playwright的代理抓包,把黄金时段的网络交互记录下来,对照分析。dial的困扰是串行时极少出现,并行后偶发,一旦出现基本就是环境层面的问题,这个方向要有意识。
6. 值得长期坚持的三个工程习惯
执行策略的优化不是一个一次性动作,而是一个持续迭代的过程。最后分享三个我觉得值得长期坚持的习惯。
第一个习惯是每次都看一眼测试耗时分布。HTML Report里的按文件耗时排名,值得每次跑完都浏览一下。哪个文件越来越胖,哪类操作从秒级拖成分钟级,都在这些数字里暴露无遗。发现异常就去处理,别等到全量耗时突破心理预期才回头补救。我很多执行方案的调整都源于对这种耗时分布的持续观察。
第二个习惯是给关键链路打执行标记。不是所有测试场景都要追求并行极致。我在项目里会明确区分三条执行链路:提交时快速验证、合并主分支时全量回归、发布前跨浏览器矩阵巡检。不同链路用不同的执行策略,而不是一套配置跑到底。这套思路本质上就是把策略配置工程化,而不是临时手工改参数。
第三个习惯是定期做"垃圾清理"。给存储状态文件做版本化、定期清理历史trace文件、删除已经完全无用的测试产物。并行和分布式执行会加速产生这些碎片文件。不清理的话,时间一长磁盘被占满,CI机器的性能会断崖式下降。技术上不复杂,但容易忘记,值得刻意维护。
我在实际项目里最终沉淀下来的执行方案是:冒烟测试串行保稳定性,全量回归按业务域分片、每台片内再开4个worker并行,跨浏览器矩阵独立分配机器跑。这个组合在800条用例规模上把单次回归耗时从6个小时压缩到了25分钟以内,关键是失败率没有因此上升。过程中踩过的大多数坑,写在了前面几个章节里,希望读到这里的同行能绕开它们。如果你正在为测试耗时和稳定性两头烦,从串行起步,按章节顺序逐层加码执行策略,大概率能找到属于你自己项目的舒适区。