Playwright 的 worker 进程模型:一个 worker 里跑几个用例

2026-08-18

有个问题我被问过好几次:workers 设成 4,为什么日志里 worker 的编号一路涨到十几;明明是同一个 spec 文件,beforeAll 却跑了两遍。这些现象不是玄学,Playwright 仓库里把调度逻辑写得相当直白,顺着读一遍就能解释。

下面这条路径从配置项出发,经过用例分组、worker 复用判定,最后落到失败重建,用到的文件都在 github.com/microsoft/playwright 仓库里。该项目迭代频繁,文中的路径与字段以仓库最新内容为准。

workers 配的不是进程数,是槽位数

先看配置本身。docs/src/test-api/class-testconfig.mdTestConfig.workers 标注 since: v1.10,类型是 ?<[int]|[string]>,文档写明「可以设成逻辑 CPU 核数的百分比,例如 '50%'」,并说明默认值是逻辑 CPU 核数的一半——这是仓库当前文档里的默认值,随版本可能变动。命令行侧在 docs/src/test-cli-js.md 的选项表里是 -j <workers>--workers <workers>

docs/src/test-parallel-js.md 给的两种写法是这样的:

npx playwright test --workers 4
import { defineConfig } from '@playwright/test';

export default defineConfig({
  // Limit the number of workers on CI, use default locally
  workers: process.env.CI ? 2 : undefined,
});

这两段原样抄自该文档,其中的 42 只是仓库里的示例值。

关键在于这个数字在代码里被怎么用。packages/playwright/src/runner/dispatcher.tsrun() 方法里有这么一段:

// 1. Allocate workers.
for (let i = 0; i < this._testRun.config.config.workers; i++)
  this._workerSlots.push({});

workers 决定的是 _workerSlots 数组的长度,也就是同时最多有几个槽位。槽位是常驻的,槽位里的 worker 进程则是会被杀掉重建的。所以 worker 编号涨得比 workers 大,一点都不矛盾——这是两个不同的计数。

用例先被打包成 job,不是一个一个派发

调度器拿到的不是单个用例,而是一组组 job。分组逻辑在 packages/playwright/src/runner/testGroups.tscreateTestGroups(),函数开头的注释直接列出了「不能放进同一个 worker」的几种情况:属于不同 project、repeatEachIndex 不同、worker fixture 的集合不同、requireFile 不同(这一条是复用 worker 但每个 requireFile 分开跑)、以及属于 parallel suite。

parallel 那部分又细分成三类,注释里写得很清楚:没有 beforeAll/afterAll 的 parallel 用例各自独立成组;带 all hooks 的先塞进 parallelWithHooks,之后按

const parallelWithHooksGroupSize = Math.ceil(withRequireFile.parallelWithHooks.tests.length / expectedParallelism);

均分;嵌在 parallel 里的 serial suite 则整组走。这里的 expectedParallelismpackages/playwright/src/runner/tasks.ts 里传的正是 testRun.config.config.workers

所以「一个 worker 里跑几个用例」这个问题,答案分两层:默认模式下,一个文件里的用例整体成一个 job,一个 worker 顺序跑完;开了 fullyParalleltest.describe.configure({ mode: 'parallel' }) 之后,带 all hooks 的那批会被按你配的 workers 数切成几份,切完每份仍然是同一个 worker 顺序跑。这也解释了为什么 docs/src/test-parallel-js.md 会强调:并行用例在不同 worker 进程里,不能共享状态或全局变量,每个用例都会为自己跑一遍相关的 hook,包括 beforeAllafterAll

复用的判据是 workerHash,而它只认 worker 级 fixture

每个 job 带一个 workerHash。它的构成在 packages/playwright/src/common/suiteUtils.ts 里:

test._workerHash = `${project.id}-${test._poolDigest}-${repeatEachIndex}`;

三段:project 的 id、fixture pool 的 digest、repeatEachIndex。前后两段好理解,中间那段值得往下挖一层。_poolDigestpackages/playwright/src/common/poolBuilder.ts 赋值,取自 FixturePool.digest;而 packages/playwright/src/common/fixtures.ts 里算这个 digest 的代码是:

const hash = crypto.createHash('sha1');
for (const name of names) {
  const registration = this._registrations.get(name)!;
  if (registration.scope === 'worker')
    hash.update(registration.id + ';');
}
return hash.digest('hex');

只有 scope === 'worker' 的注册项参与哈希。这一处和 docs/src/test-fixtures-js.md 里那句话是对得上的——文档说 Playwright Test 会尽量把 worker 进程复用给更多测试文件,「前提是它们的 worker fixture 一致、因而环境相同」。

放到实际写法上就是:test 级 fixture 怎么换、test.use() 改了多少个普通选项,都不影响 worker 复用;只要动了 worker 级 fixture 或 worker 级 option,hash 就变了,worker 必须重建。

由此还派生出一条容易踩的限制。fixtures.ts 里有这么一段校验:

if (options.scope === 'worker' && disallowWorkerFixtures) {
  this._addLoadError(`Cannot use({ ${name} }) in a describe group, because it forces a new worker.\nMake it top-level in the test file or put in the configuration file.`, location);
  continue;
}

disallowWorkerFixturespoolBuilder.ts 里对应 parent._type === 'describe'。也就是说,在 describe 块里 test.use() 一个 worker 级 fixture 会直接报加载错误,错误文案里给了理由:那会强制新建一个 worker。要改就改成文件顶层,或者写进配置文件。

worker 什么时候被推倒重来

调度主体在 dispatcher.ts_scheduleJob()_runJobInWorker()。选 worker 那步是这样写的:

// 2. Find a worker with the same hash, or just some free worker.
let workerIndex = this._workerSlots.findIndex(w => !w.jobDispatcher && w.worker && w.worker.hash() === job.workerHash && !w.worker.didSendStop());
if (workerIndex === -1)
  workerIndex = this._workerSlots.findIndex(w => !w.jobDispatcher);

先找 hash 相同、空闲、且没在停止流程里的 worker;找不到才随便挑一个空槽位。挑到空槽之后,_runJobInWorker() 的第一步就是判断要不要拆:

// 1. Restart the worker if it has the wrong hash or is being stopped already.
if (worker && (worker.hash() !== job.workerHash || worker.didSendStop())) {
  await worker.stop();
  worker = undefined;

槽位里那个 worker 的 hash 对不上,就地停掉,然后第 2 步 _createWorker() 建新的。job 跑完之后还有两条收尾规则:

// 4. When worker encounters error, we stop it and create a new one.
//    We also do not keep the worker alive if it cannot serve any more jobs.
if (result.didFail)
  void worker.stop(true /* didFail */);
else if (this._isWorkerRedundant(worker))
  void worker.stop();

第二个分支里的 _isWorkerRedundant() 判据是:同一个 hash 下还活着、没在停的 worker 数量,超过了这个 hash 还排队或在跑的 job 数量——多出来的那个就没必要留着了。

失败之后:新 worker 从哪一条接着跑

第一个分支 result.didFail 就是文档里说的那条规则。docs/src/test-parallel-js.md 写明 worker 在测试失败后总会被关掉,以保证后续测试拿到干净的环境;docs/src/test-retries-js.md 的 Failures 一节给了完整时序:worker 进程 #1 起来,beforeAll 跑,第一个用例过,第二个失败,afterAll 跑;然后 worker 进程 #2 起来,beforeAll 再跑一遍,从下一个用例继续。开了 retries 的话,worker #2 会先重试那个失败的用例再往下走。

代码侧对应的是 JobDispatcher 的收尾。剩下没跑的用例被打包成 remainingJob,塞回队首:

if (result.remainingJob) {
  this._queue.unshift(result.remainingJob);

重试候选走哪条路,取决于 retryStrategydocs/src/test-api/class-testconfig.mdTestConfig.retryStrategy 标注 since: v1.62,取值是 'immediate''isolated' 两个,文档说明 'immediate' 是「一有空闲 worker 就重试,和其余用例交错进行」,'isolated' 是「所有其它用例跑完之后,在单个 worker 里一个一个重试」,并写明这是以总时长为代价换取重试与主流程之间的相互干扰更小。默认值 'immediate'packages/playwright/src/common/config.ts 里落在 takeFirst(userConfig.retryStrategy, 'immediate') 上——这是仓库当前代码里的默认值,随版本可能变动。

import { defineConfig } from '@playwright/test';

export default defineConfig({
  retries: 2,
  retryStrategy: 'isolated',
});

这段原样抄自 class-testconfig.md 的 Usage,2 是文档里的示例值。如果你要和前面的 workers 写在一起,那就是把两个文档里的字段并进同一个 defineConfig——以上为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。

有空槽位也未必派得出去

上面那段选 worker 的代码之前,还有一步 _findFirstJobToRun()。它从队首往后扫,遇到三种情况会跳过当前 job,继续往下找:

一是 isolated 重试。代码里的注释是「Isolated retries only run one at a time, after all other jobs have finished」,判据是只要还有任何槽位在跑 job,isolated 那批就一律跳过。

二是锁冲突。docs/src/test-parallel-js.md 的 Test locks 一节写明,可以给用例声明具名锁,同名锁的用例永不同时运行,而其它用例照常并行,并且锁在文件、worker 进程和 project 之间都生效:

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

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

代码侧对应 _heldLocks() 把所有在跑 job 的锁收集起来,job.locks.some(lock => heldLocks.has(lock)) 命中就跳过。文档还补了一条容易忽略的说明:默认模式和 serial 模式下,一个文件里的用例是整体顺序跑的,所以任何一个用例上声明的锁,实际上会被持有到整个文件跑完。

三是 project 级别的 worker 上限。docs/src/test-api/class-testproject.mdTestProject.workers 标注 since: v1.52,文档说明全局 TestConfig.workers 限制的是进程总数,而这个字段进一步限制该 project 能用几个;默认没有 per-project 限制。dispatcher.ts 里的实现就是统计当前跑着这个 projectId 的槽位数,超了就跳过这个 job。

两个 index 为什么一个变一个不变

回到开头那个「编号一路涨」的现象。packages/playwright/src/runner/workerHost.ts 的构造函数里:

const workerIndex = lastWorkerIndex++;

workerIndex 是模块级自增的,每建一个新 worker 就领一个新号,永不重复。而 parallelIndex_createWorker(job, index, ...) 传进来的槽位下标,取值范围就是 0 到 workers - 1

这和文档完全一致。docs/src/test-api/class-workerinfo.mdWorkerInfo.parallelIndex 写明「保证同时运行的 worker 有不同的 parallelIndex;worker 重启后新进程的 parallelIndex 不变」,WorkerInfo.workerIndex 写明重启后会拿到一个新的唯一值。两个属性都标注 since: v1.10

这两个号在 worker 进程里也以环境变量形式出现,packages/playwright/src/worker/workerMain.ts 里设置:

process.env.TEST_WORKER_INDEX = String(params.workerIndex);
process.env.TEST_PARALLEL_INDEX = String(params.parallelIndex);

选哪个是有讲究的。docs/src/test-parallel-js.mdworkerIndex 来隔离数据库用户,示例里 const userName = \user-${test.info().workerIndex}`,那个 fixture 声明成 { scope: ‘worker’ },建号和删号分别在 use()前后。如果你想要的是「一个固定的并发槽对应一份固定资源」(比如一个端口、一个设备位),那该用parallelIndex,因为它在重启后不变;反过来,想要「每个新进程一份全新数据」,才用 workerIndex`。

Windows 侧要注意的

--workers 这个参数本身在哪个 shell 里都一样,npx playwright test --workers=1 在 PowerShell、CMD、Git Bash 下写法相同。差别出在环境变量:上面配置示例里的 process.env.CI 是 Node 侧读取,跨平台一致;但如果你想在 npm script 里用 VAR=value command 这种前缀写法临时设变量,PowerShell 与 CMD 并不支持这种语法,需要改用各自 shell 的设置方式——这是通用的跨平台做法,不是 Playwright 官方内容,仓库里没有针对这一点的专门说明。

最后

把这条链子串起来看:workers 决定槽位数,createTestGroups() 决定一个 job 里装几个用例,workerHash 决定槽位里那个进程能不能留着,失败与 retryStrategy 决定新进程从哪一条接着跑。

如果你遇到「worker 频繁重建」,先别怀疑机器,去查 worker 级 fixture 或 worker 级 option 是不是在不同文件里被改成了不同的值——digest 只认它们。如果你遇到「beforeAll 跑了不止一遍」,那多半是有用例失败触发了 worker 重建,或者你开了 parallel 模式,文档已经明说这种模式下每个用例都会为自己跑一遍 beforeAll


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

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