如果你的自动化测试跑到第30分钟还没出结果,大概率不是用例写得不好,而是执行策略没搭对。我见过太多项目,用例设计得挺用心,却在“怎么把这一千多条用例跑完”这件事上反复卡壳——要么一条条慢吞吞地串行跑,要么开了并行之后天天冒偶发失败,要么把用例拆到好几台机器上之后发现报告全丢了。Playwright测试执行策略,绕来绕去就是顺序、并行、分布式这三件事。把这三件事吃透,自动化测试的吞吐量至少能翻两三倍,而且踩坑的概率会直线下降。这篇文章就围绕这三个关键词展开,适合那些正在维护Playwright用例集、想把回归时间降下来、准备接入CI稳定执行的团队。
先说结论:顺序执行是兜底方案,并行执行是默认提速手段,分布式执行是规模化之后的扩容方向。三者不是替代关系,而是根据用例特性、环境资源和团队阶段动态组合的关系。下面我按实际落地的顺序来拆解,从原理到配置,再到问题排查,尽可能把执行策略这层窗户纸捅破。
1. 三种执行模式的本质区别与选型思路
1.1 为什么三种模式各有各的适用场景
顺序执行,就是一条用例跑完再跑下一条,整个测试过程只有一个执行流。这种模式的优点是执行上下文完全可预测:前面的用例如果创建了订单,后面的用例可以直接去查询这个订单;前面的用例如果登录了系统,后面的用例可以继续沿用登录态。缺点是速度慢,一千条用例哪怕每条只要十秒,也要将近三个小时。所以在小规模用例集、强依赖业务链路、以及调试定位问题的阶段,顺序执行是主力。
并行执行,核心是“隔离换速度”。多个worker进程同时跑不同的测试文件,互不干扰,整体耗时由最慢的那条用例和最少的那一批worker共同决定。举个直观的数字:一台8核机器,把100条独立用例分到8个进程里跑,理想耗时是串行的八分之一。当然现实世界里不可能那么理想,因为每条用例耗时不一样,还有数据隔离、资源抢占、报告汇总等额外开销。但就算打五折,速度提升也很可观。
分布式执行,就是把用例分片到多台机器上。当单台机器的CPU和内存已经撑不住并发规模,或者回归时间被单机硬件锁死到临界点,就需要横向扩容。分片方式很简单,每台机器领走一部分用例,跑完再把结果汇总。分布式执行适合用例规模上千甚至上万、且已经做好数据隔离的成熟项目,它的成本也是最高的:环境同步、结果收集、资源监控都得跟上。
1.2 选型背后的技术权衡
很多人以为执行策略就是“越快越好”,这其实是个误区。速度只是结果,真正决定策略的是三件事:用例之间的依赖强度、测试数据的隔离程度、以及机器资源的规模上限。
如果用例之间强依赖,并行就会变成灾难。比如一个用例依赖上一个用例在界面上创建的记录,那并行之后必然是随机失败。如果测试数据没有隔离好,多进程同时写同一张表、操作同一个账号,各种脏数据就会互相污染。反过来说,如果每一条用例都可以独立生成自己的数据、独立准备前置状态,那么并行几乎没有什么心理负担。
在实际项目里,我建议先用“依赖分析”来分桶:强依赖链路放进串行组,弱依赖的独立用例放进并行组,两组用不同的配置跑。这比一刀切全并行要稳得多。资源规模方面,单机8核16GB以下,用并行就够了;一旦并发打到几十个worker,内存先爆,这种情况才需要看分布式。
1.3 一张表理清三种模式的核心差异
| 维度 | 顺序执行 | 并行执行 | 分布式执行 |
|---|---|---|---|
| 执行单元 | 单进程单线程 | 多进程多文件 | 多机多分片 |
| 速度 | 最慢 | 较快 | 最快 |
| 隔离要求 | 低,可共享状态 | 中,需数据隔离 | 高,需环境一致 |
| 调试难度 | 低,问题复现容易 | 中,需区分环境干扰 | 高,需跨机排查 |
| 适用用例量级 | 百级以内 | 百到千级 | 千级以上 |
| 成本 | 低 | 中 | 高 |
这张表是我在多个项目里反复验证过的分类方式。选型的时候不用纠结“高端方案”,先对号入座:你的用例数量、隔离状态和机器资源决定了你现阶段该用哪一招。
2. 顺序执行落地:串行模式与状态管理
2.1 Playwright的串行执行配置与依赖用例
在Playwright的JS/TS版本里,串行控制最直接的方式是test.describe.serial。我把一段常见的代码贴出来:
import { test, expect } from '@playwright/test'; test.describe.serial('订单全流程', () => { test('创建订单', async ({ page }) => { await page.goto('/orders/new'); // 填写表单、提交 await expect(page.locator('.success')).toBeVisible(); }); test('支付订单', async ({ page }) => { // 依赖上一个用例创建的订单数据 await page.goto('/orders/list'); await page.locator('.pay-btn').first().click(); await expect(page.locator('.paid')).toBeVisible(); }); });关键点在于:test.describe.serial块内部的测试默认按顺序执行,而且如果前一个用例失败,后面没跑完的用例会被直接标记为跳过,不会继续执行。这非常符合业务链路的逻辑——支付关联的订单都不存在了,后面去查订单详情没有任何意义。
如果你用的是Python生态,pytest-playwright配合pytest-order插件可以做到类似效果,用装饰器指定顺序:
import pytest @pytest.mark.order(1) def test_create_order(page): page.goto("/orders/new") # ... @pytest.mark.order(2) def test_pay_order(page): # ...Python方案的优点是pytest生态丰富,缺点是没有原生串行块那么直观。所以我的建议是:如果团队主语言是TypeScript,直接用test.describe.serial;如果是Python,老老实实用插件,并且把依赖链写清楚。
2.2 顺序执行中的状态处理与实践细节
顺序执行最大的“福利”是可以共享状态,但你得管好状态,否则串行比并行更容易踩雷。最常见的做法是全局登录一次,保存storageState,后续用例直接复用登录态。在playwright.config.ts里这样配置:
import { defineConfig } from '@playwright/test'; export default defineConfig({ globalSetup: './global-setup', use: { storageState: './storageState.json', }, });global-setup.ts里负责启动浏览器、登录、把cookie和localStorage写入storageState.json。这样所有用例启动时都会自动加载登录态,顺序执行时尤其省时间。
但这里有个很多人忽略的坑:storageState里不仅包含登录态,还包含一些动态数据,比如上次访问的页面路径、临时token。如果某个用例把localStorage里的字段改乱了,后面的用例就会继承这个脏状态。我的建议是:除了登录必需的字段,其余都别依赖storageState保存,用例内部需要什么就自己构造什么。
另外,顺序执行下的数据库记录清理也不能偷懒。前面的用例在数据库里插了一条记录,后面的用例如果没用到它,最好在用例结束后自行清理,否则跑完全套,测试库里全是垃圾数据,下一次回归很可能因为数据量膨胀而变慢,甚至在查询接口里出现超时。
2.3 什么时候必须退回到顺序执行
我见过不少团队,为了追求速度强开并行,结果每轮跑完都有五六条偶发失败,排查半天发现是数据冲突。这种时候就别硬扛了,退回顺序执行,先把用例本身的正确性焊死。
必须用顺序执行的典型场景有三个。一是强业务链路,比如订单创建到支付再到退款,每一步都依赖前一步产生的数据,强拆并行只会自找麻烦。二是共享互斥资源,比如被测系统只允许同一账号单点登录,两个worker用同一个账号同时操作,后登录的会把先登录的踢下线。三是需要精确回放的复杂流程,比如一笔支付请求要校验流水号唯一,这种场景下并发会导致不可控的竞态。
还有一个实用技巧:排查问题阶段也建议临时用workers=1跑一遍。命令是npx playwright test --workers=1。一旦你确认在串行模式下用例全部通过,再切回并行模式,就能快速定位问题到底是用例自身缺陷还是并行环境下引入的干扰。
3. 并行执行提速:worker机制与用例隔离
3.1 并行方式的两种打开方式
Playwright的并行能力来自worker进程。一个worker就是一个独立进程,跑一组测试文件,拥有自己的浏览器实例。默认情况下,worker数量是CPU核心数的一半左右,但你可以在配置文件里显式指定:
export default defineConfig({ workers: 4, // 或者 fullyParallel: true,让单个文件内的用例也能被打散到多个worker上并行跑 });命令行方式是npx playwright test --workers=4,实测下来命令行优先级更高。另一个参数fullyParallel很多人容易搞混,我解释一下:默认情况下,多个文件之间是并行的,但同一个文件内部的用例还是按顺序跑;如果你把fullyParallel打开,文件内部那些彼此独立的用例也会被多个worker并行执行。这个开关适合那些把很多用例堆在同一个文件里的项目。
Python生态里对应的方案是pytest-xdist,跑的时候加上-n参数:
pytest --base-url=http://localhost:8080 -n 4-n auto会根据CPU核心数自动决定进程数。pytest-xdis的另一个好处是--dist=loadscope,它会把同一个文件里的用例尽量分配给同一个worker,避免文件和文件之间的用例切来切去导致fixture重复执行。
3.2 worker数量到底怎么定才合理
关于worker数量,网上说法五花八门,有说两倍核心数的,有说等于核心数的。我的实测经验是:不要死记倍数,要看两个硬指标——内存和IO。
一个Chromium渲染进程大概需要300到500MB内存,这还没算页面本身的资源占用。如果你在8核16GB的机器上开16个worker,大概率跑到一半出现资源耗尽,整机卡死。稳妥起见,我一般先把worker数设为核心数,然后跑一轮监控内存峰值。如果内存峰值稳定在物理内存的60%以下,再逐步往上加;一旦出现内存抖动,或者页面加载时间明显变长,就降下来。
其次要看被测应用的承载能力。有些后端服务在本地开发模式下扛不住十几个并发请求,接口直接超时。这种情况属于“能力错配”,你再怎么调worker数都没用。我的做法是给测试环境配一个最简单的压测探测:先用4个worker跑小样本用例集,观察接口P95响应时间。如果P95比起串行时翻倍了,多半是服务端瓶颈,worker数开得越大反而越慢。
最后提一个参数:--max-failures。并行模式下如果失败用例太多,全跑完要花很多时间,可以设置失败次数上限,比如失败超过10条就提前终止。这个参数在CI里特别有用,能省掉大量无谓的执行时间。
3.3 用例隔离:并行测试的地基工程
我可以负责任地说,并行测试里90%的偶发失败都是隔离没做好。所谓隔离,不光是Playwright给每个测试创建独立BrowserContext这么简单,更重要的是业务层面的数据隔离和状态隔离。
数据隔离最基础的要求:用例里不要写死手机号、用户名、订单号。我通常用一个生成器,每次跑测试都生成随机且唯一的标识:
function randomUser() { const suffix = Date.now() + Math.floor(Math.random() * 10000); return { phone: `138${suffix.toString().slice(-8)}`, email: `test${suffix}@example.com`, name: `tester_${suffix}`, }; }这么做不是因为“随机性好”,而是因为并行的两个worker可能在同一个毫秒级窗口内注册同一个写死的手机号,数据库唯一索引直接冲突。用时间戳+随机数+环境前缀三件套,基本可以避开冲突。
状态隔离方面,最常见的坑是localStorage和cookie。虽然每个测试有独立的BrowserContext,但如果你在代码里手动写了一个全局session管理器,把token放在模块变量里,那么这个token会跨用例共享。并行跑的时候,后创建的token可能覆盖先创建的token,前面的用例瞬间变成未登录状态。正确的做法是token跟着fixture走,每个测试自己获取或生成,而不是塞进公共变量。
3.4 并行场景下的共享数据与fixture设计
并行带来的另一个挑战是全局初始化操作会重复执行。比如你有一个“准备测试数据”的步骤,串行时跑一次就够;并行时如果有4个worker,它会跑4次。如果这个操作是幂等的(比如创建一批只读配置),那还好;如果它会往数据库里插入记录,很可能就重复插了。
Playwright的fixture体系里有个关键概念叫scope。默认scope是test的,意思是每个测试都会重新创建;如果你希望某个fixture在每个worker里只创建一次,可以设置scope: 'worker':
const test = base.extend({ // 每个worker只登录一次,并共享storageState workerToken: [async ({}, use) => { const token = await loginAndGetToken(); await use(token); }, { scope: 'worker' }], });这个设计的妙处在于:它既保持了并发隔离的安全性,又避免了重复登录带来的资源浪费。我强烈建议把所有高成本的准备动作(登录、初始化配置、生成基础数据)做成worker级fixture,把低成本的个性化数据做成test级fixture。
还有一个容易被忽略的点:并行模式下,测试之间不要共享文件。比如两个worker同时往同一个临时文件里写日志,或者往同一个截图目录里写同名文件,直接互相覆盖。正确的做法是每个测试用自己的输出目录,或者让Playwright自动管理test-results目录,不要在用例代码里手动拼接固定路径。
4. 从单机到集群:分布式测试的工程实现
4.1 Playwright的sharding分片机制
当单台机器并行到极限仍然不够快时,就该上分布式了。Playwright原生支持sharding,用--shard参数把整套用例按比例切成几份:
npx playwright test --shard=1/4 npx playwright test --shard=2/4 npx playwright test --shard=3/4 npx playwright test --shard=4/4这里的1/4表示四份里的第一份。四台机器各跑一条命令,整体回归时间理论上可以压缩到原来的四分之一。shard参数在CI的矩阵策略里特别好用,后面我会讲到具体配置。
有一点要注意:Playwright的sharding是以测试文件为最小分配单位。也就是说,一个文件会被完整分配到某个shard里,不会把一个文件内部的用例切开分到不同机器。这个特性决定了用例文件的粒度设计很重要:如果你把所有用例堆在一个大文件里,那么这个文件只会落在某一台机器上,其他机器闲得要死,这一台却跑到天荒地老。
4.2 基于CI的分布式执行方案
分布式测试落地最普遍的地方就是CI。以GitHub Actions为例,用matrix矩阵来天然生成多个并行任务:
name: playwright-tests on: schedule: - cron: '0 2 * * *' jobs: e2e: runs-on: ubuntu-latest strategy: matrix: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx playwright install --with-deps - run: npx playwright test --shard=${{ matrix.shard }}/4 - uses: actions/upload-artifact@v4 if: always() with: name: test-results-${{ matrix.shard }} path: test-results/这个配置的价值在于:四台独立的runner同时开工,每一台只跑四分之一用例。无论你是自己搭的Jenkins集群,还是云上弹性节点,思路都是同理的——用多个runner承担同一套回归任务。
Jenkins里可以配置多个并行stage,每个stage执行不同的shard命令,再把产物统一归档。关键点是每个分片必须独立携带自己的报告和trace,否则最后查问题的时候素材全丢了。
4.3 分布式下的报告汇总与artifact收集
分布式执行最容易被低估的工作是报告合并。一开始我的做法是每台机器各自生成一份HTML报告,测试结束之后人工打开四份报告,分别看哪些用例挂了。这样不但低效,而且用例名一多根本分不清哪条跑在哪台机器上。
Playwright后来提供了Blob Report的合并方案,专门解决这个问题。每个分片机器生成blob格式的中间报告,最后用merge-reports统一合并成一份HTML报告:
npx playwright test --shard=1/4 --reporter=blob npx playwright test --shard=2/4 --reporter=blob把各分片机器输出的blob-report目录全部收集到一起后,再执行:
npx playwright merge-reports --reporter html ./blob-report合并出来的完整报告包含所有分片的测试结果,还有一个聚合后的时间线,排查失败用例时不用来回切换工具。我这个流程已经稳定用了大半年,每次分布式的回归结果都在庄重报告里一目了然。强烈建议大家不用零散HTML,直接上blob合并。
4.4 分布式下的环境一致性与数据互斥问题
分布式测试最头疼的问题不是“跑不快”,而是“同一套代码在不同机器上结果不一样”。这个问题多半出在环境一致性上:A机器的Node版本是20,B机器是22;A机器的浏览器缓存了旧版本资源,B机器是全新环境。踩过几次坑之后,我现在要求分布式集群里所有运行镜像统一,用Docker容器预装好Node、Playwright、浏览器三件套,并且固定版本号。
数据互斥是另一个大坑。四台机器同时跑,如果都去操作同一个账号或者同一批数据,必然产生冲突。我的建议是给不同分片分配不同的数据域:比如shard 1用test_data_shard1前缀的账号,shard 2用test_data_shard2前缀的账号,以此类推。测试数据生成器里加上shard标识参数,从源头隔离。
还有一个容易被忽略的点:每台机器上的“当前时间”可能不一致。如果用例里有跟时间相关的断言,比如校验创建时间小于当前时间,那不同机器之间的时钟偏移会导致偶发失败。我的做法是在分布式执行里统一不用真实时间做断言,改用接口返回的时间字段相对值,或者用mock时钟固定时间。
5. 常见问题与排查方法实录
5.1 问题速查表
| 问题现象 | 排查方向 | 解决方案 |
|---|---|---|
| 并行后偶发失败 | 多个worker冲突 | 随机唯一数据 + 独立BrowserContext |
| 接口频繁超时 | worker数过多 | 调低workers,观察服务端P95耗时 |
| 本地通过CI挂 | 环境变量或依赖版本不一致 | 统一镜像与Node版本,固定依赖锁文件 |
| 多台机器结果不一致 | 数据环境不同 | 分片分配独立数据域,重建预置环境 |
| 顺序用例失败连带后续失败 | 依赖链未处理 | 用test.describe.serial或标记跳过 |
| 报告只显示部分结果 | 分片报告未合并 | 用blob report + merge-reports |
| 登录态在并行下失效 | 共享账号互踢 | 每个worker独立账号或token化 |
| 单个文件时长特别长 | shard粒度卡文件 | 拆分大文件,让分片粒度更细 |
这张表基本涵盖了我接手过的项目里80%的执行策略问题。看到现象先别急着改代码,按表格里的排查方向找根因,大多数时候是环境或设计问题,不是Playwright本身的问题。
5.2 我在实操中踩过的一些坑
坑一:随机数据生成器没加环境前缀。早期我们的测试数据生成器只用了时间戳加随机数,本地和CI同时跑的时候,两个环境可能生成同一个手机号。数据库唯一索引冲突直接导致“近10%用例无规律失败”,排查了两天才定位到是数据生成撞车。后来给生成器的前缀加上环境名(local、staging、prod),冲突问题彻底消失。
坑二:并行全开之后忘了管测试账号。有一段时间我们的UI自动化全部用同一个管理员账号登录,workers从2提到6以后,频繁出现“登录后马上被登出”,页面报401,但串行跑得好好的。原因是多个worker同时用同一账号登录,最新登录把之前的session全部挤掉。后来改成按worker数量准备账号池,每个worker持有独立账号,问题当场消失。
坑三:仓库里所有用例写在一个大文件里。有个同事习惯把全部测试堆在一个spec文件里,本地串行跑没事,上CI做4分片之后就发现——3台机器十几秒就结束,1台机器跑了半小时。根因就是shard的最小分配单位是文件,一个巨型文件只能落在同一台机器上。后来做了文件级拆分,按业务模块分成8个文件,分片均衡多了。
坑四:分布式下trace/image没有及时上传。我们第一次搭分布式时,只把最终HTML报告传到了制品库,各个分片机器上的trace和视频全丢了。遇到失败用例根本没法排查。后来增加了upload-artifact步骤,把test-results目录整体上传,每个分片独立命名,排查效率立竿见影。
坑五:环境变量在不同机器上不一致。有个阶段的CI配置里,BASE_URL在部分runner上没有设置,导致分片跑到登录后重定向到localhost。后来我把所有环境变量都写进CI的env统一配置,并且在测试启动前加一层配置自检,缺失变量直接报错,而不是带病执行。
5.3 排查偶发失败的一套组合拳
偶发失败是执行策略项目里最常见的“慢性病”,特征是跑十次有九次通过,就偶尔挂一两条。我的排查路径有一套固定的组合拳,分享给大家。
第一步固定参数复现。先用--workers=1串行跑,挂的用例如果不再出现,基本可以判断是并行干扰;如果串行还挂,那是用例自身的bug。
第二步看trace。Playwright会在失败时自动录制trace,直接在HTML报告里打开,看是网络请求导致的超时,还是页面元素定位异常。trace里还包含了API请求和响应,比截图信息量大得多。
第三步看浏览器控制台和网络日志。把page.on('console')和page.on('requestfailed')在fixture里挂上,把输出写到测试报告里。偶发失败很多时候在界面上看不出来,但控制台里的js异常会提前暴露问题。
第四步做数据痕迹比对。把失败用例的输入数据和通过时的输入数据放在一起对比,重点看有没有相同的手机号、订单号、时间戳。如果发现有“写死痕迹”,直接改成随机数据生成器。
这套组合拳在大多数场景下10分钟内能定位问题根因。真正要避免的是一遇到偶发失败就“重跑一遍试试”,不加分析,那样问题永远隐藏在黑暗里。
最后聊点实操后的真心话。执行策略这件事,我在不同规模的项目里反复调整过多次,最深的体会是:顺序、并行、分布式,不是一个由低到高的进阶菜单,而是一套组合工具。小项目在串行下能稳定跑完,就没有必要为了“看起来高级”硬上并行;中等规模先把并行做好,让每条用例真的可以独立运行,比盲目追求worker数量更有价值;只有用例规模确实大到单机资源捉襟见肘,再引入分布式,并花力气把环境一致性、报告合并、数据域隔离这三件套彻底打通。
我个人在实际操作中还有一个习惯,每次调整执行策略后,会留存一轮完整的执行日志作为基线,包含总耗时、失败率、每个分片耗时分布。下一轮改动之后对比基线数据,效果好坏一眼就能看出来。如果你正在为慢吞吞的回归发愁,不用着急,先从串行稳定性做起,再逐步放开并行,最后再看分布式——这条路我走过很多遍,每一步的收益都比想象中扎实。