并行跑就互相干扰:Playwright 哪些用例必须串行、怎么划范围

2026-08-18

现象长什么样

典型的样子是这样:本地开发时一个文件一个文件地跑,全绿;推到 CI 上跑全量,每次红的用例都不一样。红的往往集中在几类操作上——改用户设置、改某个全局开关、往同一个后端记录里写、往同一个路径导出文件。单独重跑那个失败的用例又是绿的。

这类问题的共同点是:失败与用例本身的逻辑无关,与同时还有谁在跑有关。

第一步:先确认问题真的出在并发上

不要一上来就改 describe。先做两个可执行的判定动作。

动作一,把并发关掉再跑一遍。 docs/src/test-parallel-js.md 的 “Disable parallelism” 一节写明,把 worker 数限制为一个就能关掉全部并行:

npx playwright test --workers=1

也可以在配置文件里写 workers: 1。如果 --workers=1 之后稳定全绿,而并行跑就随机红,基本可以锁定在并发这条线上。

Windows 这边有一点要留意:仓库文档里两种写法都出现过,docs/src/test-parallel-js.md 的 “Limit workers” 一节用的是 npx playwright test --workers 4(空格),“Disable parallelism” 一节用的是 --workers=1(等号)。在 PowerShell 里用等号形式可以少踩一层参数解析的坑——这是通用做法,不是该项目官方文档的内容。

动作二,确认实际并发度。 列表报告器开头会打印一行 Running <N> tests using <M> workers 形式的信息(docs/src/test-reporters-js.mddocs/src/test-retries-js.md 的示例输出里都是这个格式)。先看清楚 CI 上到底起了几个 worker,再谈冲突。docs/src/test-api/class-testconfig.mdTestConfig.workers 的类型是 intstring,可以写成逻辑核数的百分比,例如 '50%';文档写明它默认取逻辑 CPU 核心数的一半——这是仓库当前文档里的默认值,随版本可能变动。

worker 到底隔离了什么

搞清楚这条边界,才知道该往哪儿找共享资源。

docs/src/test-parallel-js.md 的 “Worker processes” 一节写明:所有测试都在 worker 进程里跑,这些是独立运行的操作系统进程,由 test runner 编排;所有 worker 环境一致,各自启动自己的浏览器worker 之间不能通信。另外该节还写明,测试失败后 worker 一律被关闭,以保证后续测试拿到干净环境;docs/src/test-retries-js.md 把这件事说得更细:任何测试失败,runner 会连同浏览器一起丢弃整个 worker 进程,另起一个新的从下一个测试继续。

“Avoiding shared state in parallel tests” 一节则写明,每个 worker 有自己隔离的 BrowserContext,cookie、storage 和内存里的全局变量本来就是隔离的;不稳定来自活在单个测试之外的状态。

把这两段放在一起,结论很直接:跨 worker 的冲突不可能是 cookie 或 JS 全局变量,只能是进程外面的东西——后端数据库里的同一条记录、同一个外部服务、磁盘上的同一个路径、账号级别的全局设置。排查时直接从这几类里找,不用在浏览器状态上浪费时间。

test.describe.configure 的作用域怎么算

docs/src/test-api/class-test.mdTest.describe.configure 的说明有一句很容易被略过:它配置的是所在的 scope,可以写在顶层也可以写在某个 describe 内部,而且不管它写在测试声明之前还是之后,都作用于整个 scope。所以「我把它放在文件最后所以不生效」这个猜想是不成立的。

mode 的取值是 "default" | "parallel" | "serial" 三个。默认行为是:测试文件之间并行,单个文件里的测试按声明顺序、在同一个 worker 里跑。把范围放大的开关有三处,排查时都要看:配置文件里的 fullyParallelTestConfig.fullyParallel)、项目级的 TestProject.fullyParallel、以及命令行的 --fully-paralleldocs/src/test-cli-js.md 的选项表里写明默认 false——这是仓库当前文档里的默认值,随版本可能变动)。

作用域是可以嵌套覆盖的,文档给了外层并行、内层有序的写法:

test.describe.configure({ mode: 'parallel' });

test.describe('A, runs in parallel with B', () => {
  test.describe.configure({ mode: 'default' });
  test('in order A1', async ({ page }) => {});
  test('in order A2', async ({ page }) => {});
});

test.describe('B, runs in parallel with A', () => {
  test.describe.configure({ mode: 'default' });
  test('in order B1', async ({ page }) => {});
  test('in order B2', async ({ page }) => {});
});

反过来,如果整个配置开了 fullyParallel,又想让某一组回到默认,docs/src/test-parallel-js.md 的 “Opt out of fully parallel mode” 一节给的就是在那个 describe 里写 test.describe.configure({ mode: 'default' })

需要注意 mode: 'serial' 的代价,文档写得很明白:串行组里一个测试失败,后续测试全部被跳过,整组一起重试。而且文档单独用 note 标出「不推荐使用 serial,通常更好的做法是让测试互相隔离、可独立运行」。

优先考虑 lock,而不是把整组串起来

很多时候你并不需要「这几个用例必须按顺序」,你只是需要「这几个用例不要同时碰那个东西」。docs/src/test-parallel-js.md 的 “Test locks” 一节就是为这个场景准备的:可以在测试上声明具名锁,同名锁的测试永远不会同时运行,其它测试照常并行。

import { test, expect } from '@playwright/test';

test('update user settings', { lock: 'user-settings' }, async ({ page }) => {
  // ...
});

docs/src/test-api/class-test.md 里,lockTest.(call).detailsTest.describe.details 这个 details 对象上的一个可选字段,类型是 stringstring[],也就是说锁可以下在整个 describe 上、作用到组里每个测试。文档写明一个测试可以声明多个锁,全部锁都可用时才会开始跑;Playwright 在测试开始前把它的锁全部取得,结束时释放。作用范围写明是跨文件、跨 worker 进程、跨 project 的。

这里有一条特别容易翻车的 note,务必读到:在默认模式与 serial 模式下,一个文件里的测试是一起按顺序跑的,所以任何一个测试上声明的锁,会在整个文件运行期间一直被持有。 也就是说,如果你没开 parallel 模式,就在一个大文件里的某个用例上挂了 lock,这把锁实际上锁住的是这个文件的全过程——并发度会掉得比你预期的多。想让锁只圈住那一个用例,得先让这个文件里的测试真正并行。

让每个测试拿到只属于自己的东西

锁是用来对付「确实无法并发访问」的资源的。更多情况其实是可以做到不共享的,文档给了几个现成配方。

后端数据用 TestInfo.testId 派生唯一标识:

test('creates an order', async ({ page }, testInfo) => {
  const orderId = `order-${testInfo.testId}`;
  await page.goto(`/orders/new?id=${orderId}`);
  await expect(page.getByText(orderId)).toBeVisible();
});

写文件用 TestInfo.outputPath()docs/src/test-api/class-testinfo.md 写明它返回 TestInfo.outputDir 之内的路径,并保证并行运行的测试不会互相干扰;同一份文档还提示路径段必须留在该测试的 outputDir 里,否则会抛错。

如果一批测试可以共用一份数据,就不必每个测试造一份,改成每个 worker 造一份。文档的做法是写一个 worker 作用域的 fixture,用 TestInfo.workerIndex 区分:

export const test = baseTest.extend<{}, { dbUserName: string }>({
  // Returns db user name unique for the worker.
  dbUserName: [async ({ }, use) => {
    // Use workerIndex as a unique identifier for each worker.
    const userName = `user-${test.info().workerIndex}`;
    // Initialize user in the database.
    await createUserInTestDatabase(userName);
    await use(userName);
    // Clean up after the tests are done.
    await deleteUserFromTestDatabase(userName);
  }, { scope: 'worker' }],
});

以上片段抄自仓库文档,其中的用户名拼法只是仓库里的示例值。{ scope: 'worker' } 这个元组写法的语义在 docs/src/test-fixtures-js.md 的 “Worker-scoped fixtures” 一节:worker fixture 每个 worker 进程建立一次,runner 会在 worker fixture 匹配的前提下尽量复用同一个 worker 跑多个测试文件。

workerIndex 还是 parallelIndex,值得把两处定义摆在一起看。docs/src/test-api/class-testinfo.md 写明:workerIndex 是 worker 进程的唯一索引,worker 重启后新进程会拿到一个新的 workerIndexparallelIndex 的取值在 0workers - 1 之间,同时运行的 worker 保证不同,而重启后的新进程沿用同一个 parallelIndex。两者也分别对应环境变量 process.env.TEST_WORKER_INDEXprocess.env.TEST_PARALLEL_INDEX。文档隔离测试数据的例子用的是 workerIndex。这两条摆在一起的意思是:一个是「重启就换新身份」,一个是「重启后回到同一个槽位」——挑哪个取决于你希望失败重启后拿到全新数据还是复用原槽位,文档本身没有替你做这个选择。

处置完之后怎么验证

改完不能只跑一遍绿了就算数,并发问题本来就是概率性的。可用的几个开关都在 docs/src/test-cli-js.md 的选项表里:--fully-parallel 强制全并行,--repeat-each <N> 让每个测试跑 N 遍,--workers 指定并发数,--max-failures <N>(或 -x)在累计 N 个失败后停下来。一种组合是先用最大并发加重复跑逼一逼,再和 --workers=1 的结果对照:

npx playwright test --fully-parallel --repeat-each=5 --max-failures=10

以上为按仓库文档中的参数语义组合的示例,未经实测,以仓库最新内容与 --help 的实际输出为准。

判定标准是:并行与单 worker 两种跑法的结果应当一致。如果加了 lock 之后并行还是随机红,说明还有一个你没识别出来的共享资源,回到「worker 隔离了什么」那一节重新列一遍进程外部的依赖。

什么情况说明不是这个原因

这一步不能省,否则很容易把时间花在改并发配置上。

  • --workers=1 也照样红。 并发不是原因。这时候要查的是用例本身、被测应用的状态,或者环境差异。
  • 报告开头显示只用了一个 worker,却仍然不稳定。 同上,此时不存在跨 worker 的并发。
  • 冲突发生在同一个文件内,而你并没有开 fullyParallel、也没写 mode: 'parallel' 默认行为是单文件内按声明顺序、同一 worker 跑,这种情况下的互相影响不是并行干扰,而是测试之间泄漏了状态。docs/src/test-parallel-js.md 的 “Keep tests independent” 一节把这个场景写得很清楚:通过模块级变量泄漏状态、或者依赖前一个测试副作用的测试,按顺序跑是好的,一旦并行或换个顺序就坏——处置方向是把依赖搬进测试自身或 fixture,而不是去调 worker 数。
  • 失败集中在某个 worker 重启之后的那一段。 参考上面 worker 失败即丢弃、worker fixture 随之重建那两条,先确认不是 fixture 重建带来的数据状态差异。
  • 跨机器分片的场景。 --shard 把用例分到多台机器上执行(docs/src/test-parallel-js.md 的 “Shard tests between multiple machines” 一节给的例子是 npx playwright test --shard=2/3,细节另见 docs/src/test-sharding-js.md)。docs/src/test-parallel-js.md 对 lock 的描述写明其作用范围是跨文件、跨 worker 进程、跨 project;至于分到不同机器上的进程之间锁是否生效,我们在这份文档里没有找到相关说明,需要的话请以仓库最新内容为准,不要按「反正是锁」来假定。

本文依据 github.com/microsoft/playwright 仓库于 2026-08-18 的公开内容整理, 事实来自仓库内的文档与源码。我们没有对文中涉及的功能做过实测, 因此不涉及运行速度、稳定性与实际表现的任何描述。 该项目迭代频繁,文中涉及的 API 签名、配置项与默认值随版本变动,请以仓库最新内容为准。 本文不涉及浏览器版本矩阵与版本清单,相关信息请以官方发布说明为准。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。