Playwright fixture 的生命周期:test 级和 worker 级到底差在哪
写自定义 fixture 的人迟早会撞上同一个问题:我在 base.extend 里注册了一个建账号、起服务的 fixture,它到底是每条 test 跑一次,还是整个进程跑一次?CI 上某条用例失败重试了,这个 fixture 会不会跟着重来一遍?
这两个问题的答案不在某段说明文字里,而在 scope 这个选项和 Playwright Test 的 worker 进程模型交叉的地方。下面沿着仓库里的文档和源码走一遍。
起点:scope 只有两个取值,默认是 test
注册 fixture 的元组语法写在 docs/src/test-fixtures-js.md 里,第二个元素就是选项对象。仓库文档给的 worker 级写法长这样:
export const test = base.extend<{}, { account: Account }>({
account: [async ({ browser }, use, workerInfo) => {
// ...
await use({ username, password });
}, { scope: 'worker' }],
});
scope 的合法值在源码里是封死的。packages/playwright/src/common/fixtures.ts 顶部有一行类型声明和一行顺序表:
export type FixtureScope = 'test' | 'worker';
const kScopeOrder: FixtureScope[] = ['test', 'worker'];
也就是说取值只有两个,没有第三种粒度——没有「每个文件一次」,也没有「每个 project 一次」。同一个文件里 _appendFixtureList 处理注册时写的是 scope: value[1].scope || 'test',而完全没给选项对象的那个分支直接写死 { auto: false, scope: 'test', option: false, timeout: undefined }。所以你用最常见的那种简写(只给一个 async 函数、不带元组),拿到的就是 test 级。这是仓库当前代码里的默认值,随版本可能变动。
顺带一个容易踩的:如果同一个 fixture 名在后续 extend 里被重新注册且换了 scope,源码会直接报加载错误,原文是 Fixture "..." has already been registered as a { scope: '...' } fixture defined in ...。scope 不是能在覆盖时顺手改掉的东西。
初始化次数:不是「声明了就会跑」
docs/src/test-fixtures-js.md 的 Execution order 一节列了三条规则,翻译成人话就是:
- A 依赖 B,则 B 先于 A 建立、晚于 A 拆除;
- 非 auto 的 fixture 是惰性的,只有 test 或 hook 真的要它才建立;
- test 级 fixture 每条 test 之后拆除,worker 级 fixture 只在 worker 进程结束时拆除。
文档里那个把 workerFixture、autoWorkerFixture、testFixture、autoTestFixture、unusedFixture 全放在一起的例子,值得对着执行顺序清单读一遍。几个不那么直觉的点:
unusedFixture 从头到尾没有被建立过,因为没有任何 test 或 hook 提到它。声明它不花钱。
workerFixture 是被 second test 通过 testFixture 间接拉起来的——它的 setup 发生在第二条 test 之前,而不是 worker 一启动就跑。但它的 teardown 只在整个 worker 收尾时执行一次。所以「一次 setup 对应一次 teardown」在 worker 级 fixture 上意味着:中间可能夹着很多条 test。
autoWorkerFixture 会为 beforeAll 建立,autoTestFixture 不会。auto 的两种 scope 在 hook 面前的行为不一样,写自动 fixture 时得分清。
拆除的落点在 packages/playwright/src/worker/workerMain.ts:每条 test 结束走 this._fixtureRunner.teardownScope('test', ...),worker 收尾那一段才调 teardownScope('worker', ...),而且代码注释里写明这次 teardown 归到 'teardown' 这个 runnable 类型,因为 worker fixture 不算 test 的一部分。
依赖是单向的
kScopeOrder 那个数组不只是个列表,validate() 里拿它做了索引比较:如果一个 fixture 的 scope 在数组里的位置比它依赖项的位置更靠后,就报错。落到实际就是——worker 级 fixture 不能依赖 test 级 fixture,反过来可以。报错原文是 worker fixture "..." cannot depend on a test fixture "..."。
落到写法上,这条约束的效果是:worker 级 fixture 拿不到那些「每条 test 之后就被拆除」的对象。仓库里没有给出这条限制的设计理由,这里也就不替它解释,只把两处代码放在一起——kScopeOrder 的顺序、teardownScope 的两个调用点,合起来就是这条报错的全部依据。文档里那个 account 的例子也是这么写的——worker 级的 account 依赖的是 browser(内置的 worker 级 fixture),然后反过来由 test 级的 page 去依赖 account。
还有一条藏在源码里的限制。_appendFixtureList 有个 disallowWorkerFixtures 参数,命中时报错原文是:
Cannot use({ ... }) in a describe group, because it forces a new worker.
Make it top-level in the test file or put in the configuration file.
也就是在 test.describe 里用 test.use 去设置 worker 级的东西是不允许的,得挪到文件顶层或配置文件。这句报错本身就交代了原因:它会逼出一个新的 worker。
worker 复用是按什么判定的
docs/src/test-parallel-js.md 写明 Playwright Test 会尽量复用同一个 worker,多个测试文件通常在一个 worker 里一个接一个跑;docs/src/test-fixtures-js.md 补了条件——「provided their worker fixtures match」。这个 match 具体怎么算,源码里能查到。
fixtures.ts 的 validate() 最后几行,对所有注册项按名字排序后遍历,只把 scope === 'worker' 的注册项的 id 喂进 sha1,得出的就是 this.digest。这个 digest 由 packages/playwright/src/common/poolBuilder.ts 写进 test._poolDigest,再由 packages/playwright/src/common/suiteUtils.ts 拼成 test._workerHash,形式是 ${project.id}-${poolDigest}-${repeatEachIndex}。调度侧 packages/playwright/src/runner/dispatcher.ts 找空闲 worker 时的条件里带着 w.worker.hash() === job.workerHash。
把这几处放在一起,结论就清楚了:test 级 fixture 怎么改都不影响 worker 的复用边界,只有 worker 级 fixture 的集合变了,才会切出一个新的 worker。 你在某个文件顶层 test.use 一个 worker 级选项,那批文件就会被分到跟别人不同的 worker 里去。
重试的时候,worker 级 fixture 会重来
这是本文最该记住的一段。docs/src/test-retries-js.md 的 Failures 一节把三种情况的时间线全列出来了。原文写明:只要有任何一条 test 失败,Playwright Test 就会连同浏览器一起丢弃整个 worker 进程,然后起一个新的。文档给的重试时间线是这样的:
Worker process #1 starts
beforeAll hook runs
first good passes
second flaky fails
afterAll hook runs
Worker process #2 starts
beforeAll hook runs again
second flaky is retried and passes
third good passes
afterAll hook runs
beforeAll 在新进程里又跑了一遍。worker 级 fixture 的生命周期跟 worker 进程绑定,所以它在新进程里同样是从头建立一次——「每个 worker 只跑一次」不等于「每次运行只跑一次」,失败次数越多,它被执行的次数越多。docs/src/test-parallel-js.md 里那句「Workers are always shutdown after a test failure」说的是同一件事。
这条对写法有直接影响。文档里的 account fixture 用 workerInfo.workerIndex 拼唯一用户名,而 docs/src/test-api/class-workerinfo.md 对这个属性写得很明确:worker 因失败重启后,新进程会拿到一个新的、唯一的 workerIndex;同一文件里的 parallelIndex 则相反——取值范围是 0 到 workers - 1,worker 重启后新进程的 parallelIndex 保持不变。两者分别也可以从 process.env.TEST_WORKER_INDEX 和 process.env.TEST_PARALLEL_INDEX 读到。WorkerInfo 这个类自 v1.10 起可用。
把这两处摆在一起:如果你在 worker 级 fixture 里按 workerIndex 建账号或建库,重试之后拿到的是一套新名字;如果按 parallelIndex 建,重试后名字跟上一次撞上。哪种更合适取决于你的清理逻辑,仓库文档没有替你做这个选择,这里也就说到为止。
另外,如果只是想在重试时做点补偿动作,docs/src/test-retries-js.md 给的是 testInfo.retry,它对 test、hook 和 fixture 都可见。
顺带一个常见用法:把 worker 级 fixture 当全局 beforeAll
docs/src/test-fixtures-js.md 最后一节给了个很实用的组合。test.beforeAll / test.afterAll 只对同一个文件、同一个 test.describe 块里的 test 生效,每个 worker 进程执行一次。想要「所有文件都生效」,文档给的做法是把它写成 scope: 'worker' 且 auto: true 的 fixture:
export const test = base.extend<{}, { forEachWorker: void }>({
forEachWorker: [async ({}, use) => {
// This code runs before all the tests in the worker process.
console.log(`Starting test worker ${test.info().workerIndex}`);
await use();
// This code runs after all the tests in the worker process.
console.log(`Stopping test worker ${test.info().workerIndex}`);
}, { scope: 'worker', auto: true }],
});
文档紧跟着提醒了一句:这样写之后 fixture 仍然是每个 worker 进程跑一次,只是不用在每个文件里重复声明。类型参数写在第二个位置(base.extend<{}, {...}>),这个位置就是 worker 级 fixture 的类型槽,写错位置 TypeScript 会先拦下来。对应的 test 级版本是 { auto: true } 不带 scope,文档里叫 forEachTest,那个才是「每条 test 跑一次」。
超时是分开算的
docs/src/test-fixtures-js.md 写明 fixture 的 setup 和 teardown 时间算进 test 超时,所以慢 fixture 会拖垮 test。可以单独给 fixture 设超时:
const test = base.extend<{ slowFixture: string }>({
slowFixture: [async ({}, use) => {
// ... perform a slow operation ...
await use('hello');
}, { timeout: 60000 }]
});
这里的 60000 是仓库文档示例里的值,不是什么推荐配置。worker 级的情况文档另说了一句:每个 worker 级 fixture 有自己独立的超时,等于 test 超时;改法同上。
以上代码片段均原样取自仓库文档,为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。
两个别串的地方
别把这套 scope 词汇套到 Python 上。 上面引的几个文档文件名都带 -js 后缀,docs/src/test-api/class-workerinfo.md 头部也标着 langs: js——test.extend 与 scope: 'worker' 是 Playwright Test(JS/TS 的 test runner)的概念。Python 侧走的是 pytest 插件,docs/src/test-runners-python.md 用的是 pytest 自己的 scope 词汇:context、page、new_context 是 function scope,playwright、browser_type、browser、browser_name 这些是 session scope。名字对得上、粒度对不上,写 Python 就读 Python 那一页。
Windows 侧。 worker 是独立的 OS 进程,这一点仓库文档对平台没有区分。限制并发数的写法两边一样,命令行 npx playwright test --workers=1,或在配置文件里写 workers。文档里那个 workers: process.env.CI ? 2 : undefined 的例子读的是环境变量(这里的数值只是仓库文档中的示例值,不是推荐配置),PowerShell、cmd 和 bash 设置环境变量的语法不同,这属于通用做法、不是该项目官方内容,按你自己的 CI 环境来。至于 worker 进程被强制终止时 worker 级 fixture 的 teardown 有没有保证,我们在仓库里没有找到相关说明,不做推断。
一句话收束
判断一个 fixture 跑几次,看三样东西:scope 是不是显式写了 'worker'(不写就是 test 级)、有没有被真的用到(惰性)、以及这次运行里 worker 有没有因为失败被丢弃重建。前两样在你自己代码里,第三样只有等这次运行结束才知道——按文档写明的规则,一个只声明过一次的 worker 级 fixture,在一次有失败的运行里会被建立不止一次。
本文依据 github.com/microsoft/playwright 仓库于 2026-08-18 的公开内容整理,
事实来自仓库内的文档与源码。我们没有对文中涉及的功能做过实测,
因此不涉及运行速度、稳定性与实际表现的任何描述。
该项目迭代频繁,文中涉及的 API 签名、配置项与默认值随版本变动,请以仓库最新内容为准。
本文不涉及浏览器版本矩阵与版本清单,相关信息请以官方发布说明为准。