并行跑就互相干扰:Playwright 哪些用例必须串行、怎么划范围
现象长什么样
典型的样子是这样:本地开发时一个文件一个文件地跑,全绿;推到 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.md、docs/src/test-retries-js.md 的示例输出里都是这个格式)。先看清楚 CI 上到底起了几个 worker,再谈冲突。docs/src/test-api/class-testconfig.md 里 TestConfig.workers 的类型是 int 或 string,可以写成逻辑核数的百分比,例如 '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.md 里 Test.describe.configure 的说明有一句很容易被略过:它配置的是所在的 scope,可以写在顶层也可以写在某个 describe 内部,而且不管它写在测试声明之前还是之后,都作用于整个 scope。所以「我把它放在文件最后所以不生效」这个猜想是不成立的。
mode 的取值是 "default" | "parallel" | "serial" 三个。默认行为是:测试文件之间并行,单个文件里的测试按声明顺序、在同一个 worker 里跑。把范围放大的开关有三处,排查时都要看:配置文件里的 fullyParallel(TestConfig.fullyParallel)、项目级的 TestProject.fullyParallel、以及命令行的 --fully-parallel(docs/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 里,lock 是 Test.(call).details 与 Test.describe.details 这个 details 对象上的一个可选字段,类型是 string 或 string[],也就是说锁可以下在整个 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 重启后新进程会拿到一个新的 workerIndex;parallelIndex 的取值在 0 到 workers - 1 之间,同时运行的 worker 保证不同,而重启后的新进程沿用同一个 parallelIndex。两者也分别对应环境变量 process.env.TEST_WORKER_INDEX 和 process.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 签名、配置项与默认值随版本变动,请以仓库最新内容为准。
本文不涉及浏览器版本矩阵与版本清单,相关信息请以官方发布说明为准。