Playwright 的配置优先级链:同一个选项在四个地方都能设
有个场景大概每个用 Playwright 写过一阵测试的人都碰到过:配置文件里全局 use 设了一个 locale,某个 project 里又设了一遍,某个 spec 文件顶部还有一行 test.use(),里面某个 describe 块里再来一行,跑的时候命令行又带了参数。结果和你想的不一样,然后你开始一个个注释掉试。
这个「试」是可以省掉的,因为顺序在仓库里是写死的,而且不长——真正决定它的就是两处代码。下面顺着这条链走一遍。
先分清:这其实是两套机制
第一个容易掉的坑,是把「配置文件里的四层」当成一条链。它不是。
docs/src/test-use-options-js.md 的 Configuration Scopes 一节写明,你可以全局配、按 project 配、按测试配三个层级,并给了三段代码:全局的 use: { locale: 'en-GB' }、project 里的 use: { ...devices['Desktop Chrome'], locale: 'de-DE' }、以及文件里的 test.use({ locale: 'fr-FR' })。同一页往下还给了 describe 里的写法:
import { test, expect } from '@playwright/test';
test.describe('french language block', () => {
test.use({ locale: 'fr-FR' });
test('example', async ({ page }) => {
// ...
});
});
文档把这四种写法排在一起讲,但从实现上看,前两层(全局 use、project use)加上命令行,是在加载配置的时候合并成一份的;而 test.use()——不管写在文件顶层还是写在 describe 里——走的是完全另一套东西,是 fixture 池的覆盖链。搞清这一点,后面所有反直觉的现象都能解释。
第一套:配置合并,一行 mergeObjects 定死
packages/playwright/src/common/config.ts 里 FullProjectInternal 的构造函数中有这么一行:
const use = mergeObjects(config.use, projectConfig.use, configCLIOverrides.use);
顺序一目了然:全局 use 打底,project 的 use 盖上去,命令行的 use 最后盖。mergeObjects 在 packages/playwright/src/util.ts 里,做的事情很朴素——以第一个对象为底,把后面对象的键逐个写进去,但有一道过滤:if (!Object.is(value, undefined))。也就是说值为 undefined 的键会被跳过,不会把前一层的值抹掉。这解释了为什么 project 里只写 locale 不会把全局的 baseURL 冲没。
不走 use 的那些顶层选项(retries、timeout、outputDir 这类)用的是另一个函数 takeFirst,它返回参数里第一个不是 undefined 的值。config.ts 里能直接读到:
retries: takeFirst(configCLIOverrides.retries, projectConfig.retries, config.retries, 0),
命令行 → project → 全局 → 兜底值。这里的 0 是仓库当前代码里的兜底值,随版本可能变动。timeout 那一行结构一样,只是前面多插了一段 configCLIOverrides.debug === 'inspector' ? 0 : undefined——--debug 会把超时顶成 0,这和 docs/src/test-cli-js.md 里对 --debug 的描述对得上:它是 PWDEBUG=1 环境变量加上 --timeout=0 --max-failures=1 --headed --workers=1 的快捷方式。
命令行到底能改 use 里的哪几个键
这是我觉得最值得单独拎出来说的一点。docs/src/test-cli-js.md 的 All Options 表长长一列,很容易让人以为命令行是万能覆盖层。实际上真正被塞进 configCLIOverrides.use 的键,在 packages/playwright/src/cli/testActions.ts 的 overridesFromOptions() 里数得清:
use: {
trace: options.trace,
},
以及后面这两行:
if (options.headed)
overrides.use.headless = false;
if (options.debug === 'inspector') {
overrides.use.headless = false;
process.env.PWDEBUG = '1';
}
就这么两个键:trace 和 headless。表里其余选项要么进的是顶层字段(走 takeFirst 那条路),要么根本不参与 use 的合并。所以「命令行传个参数把 use 里某项顶掉」这个心智模型,只在这两项上成立。
--trace 还有一个特例,代码里带着注释原样写着:--trace <mode> 只强制 tracing 的模式,保留配置里 trace 的其它选项。对应实现是——如果配置里的 trace 是个对象,命令行给的是字符串,就把模式塞进去而不是整个替换:
if (typeof configCLIOverrides.use?.trace === 'string' && typeof configTrace === 'object' && configTrace)
use.trace = { ...configTrace, mode: configCLIOverrides.use.trace };
还有个 --browser。它不进 use,而是在同一个 overridesFromOptions() 里被翻译成一整套 projects,每个 project 带一个 use: { browserName }。与之配套的检查在 packages/playwright/src/common/config.ts 的构造函数开头:如果命令行给了 --browser(即 configCLIOverrides.projects 非空)而配置文件里又定义了 projects,直接抛错,提示让你去 projects 里写 browserName。另外要说清楚的是:packages/playwright/src/program.ts 的选项表里,--browser <browser> 这一行前面挂着 /* deprecated */ 注释;而 docs/src/test-cli-js.md 里 playwright test 的两张选项表都没有列出它(该文件里出现的 -b, --browser 属于 codegen 与 show-trace 这两条命令的表,不是 test 的)。两处放在一起看,结论是别把它写进新的 CI 脚本。
顺带一提,program.ts 里那张表上方有句注释,说更新这里时要同步更新 docs/src/test-cli-js.md,并注明 program 才是权威来源(仓库源码自述)。所以当文档表和源码表对不上时,以源码那份为准。
第二套:test.use() 走的是 fixture 池
前面合并出来的那份 use,会作为 project 的最终配置传下去。它进入 test.use() 体系的入口在 packages/playwright/src/common/poolBuilder.ts 的 PoolBuilder:_buildTestTypePool() 把 this._project?.project?.use ?? {} 装进 optionOverrides.overrides 传给 FixturePool,建出基础池,然后 _buildPoolForTest() 做这件事——把当前 test 的所有父级 suite 收集起来,parents.reverse() 反转成从外到内,再逐层套:
for (const parent of parents) {
if (parent._use.length)
pool = new FixturePool(parent._use, e => this._handleLoadError(e, testErrors), pool, parent._type === 'describe');
每一层把上一层当 parentPool,越内层越后写。文件顶层的 test.use() 先套,describe 里的后套,嵌套 describe 再后套——内层赢,这个顺序就是 reverse() 那一行决定的。
于是有了那条最容易踩的结论:test.use() 的优先级高于命令行参数。因为命令行只参与到第一套机制、只到 project 那一层为止,而 fixture 池是在它之上再叠的。你在 spec 文件里写了 test.use({ headless: false }),命令行传什么都盖不回去。这不是 bug,是两套机制的层次关系,但确实反直觉。
describe 级有一道硬边界
_buildPoolForTest() 里传给 FixturePool 的第四个参数是 parent._type === 'describe',对应构造函数的 disallowWorkerFixtures。在 packages/playwright/src/common/fixtures.ts 里,命中的话会直接报错,原文是:
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.
所以「哪些选项能在 describe 里改」这个问题,答案取决于它是 worker 作用域还是 test 作用域,而这在 packages/playwright/src/index.ts 的 fixture 声明里能直接数出来。标着 scope: 'worker', option: true 的有 browserName、headless、channel、launchOptions、trace、video、screenshot 这些;而 locale、viewport、storageState、geolocation、timezoneId、permissions、extraHTTPHeaders、actionTimeout 这些声明里压根没写 scope——fixtures.ts 里注册时取的是 value[1].scope || 'test',省略就是 test 作用域。所以判断方法是:声明里显式写了 scope: 'worker' 的,describe 里设不了。
对着这条再回头看官方文档举的例子就通了:文档拿来演示 describe 里 test.use() 的,是 locale(docs/src/test-use-options-js.md)、viewport(docs/src/emulation.md)、storageState(docs/src/auth.md)这类 test 作用域选项,没有拿 headless 举例——因为后者在 describe 里根本设不了。要局部关掉 headless,只能提到文件顶层的 test.use(),或者干脆开一个 project。
fixtures.ts 里还有一处限制值得知道:如果你在配置的 use 里去覆盖一个不是用 { option: true } 注册的 fixture,会得到一条明确的错误,说只有注册为 option 的 fixture 才能在配置的 use 段里设置。自定义 fixture 想让人从配置里改,注册时就得带上 option: true。
undefined 在两套机制里含义不同
这一点很隐蔽。第一套里,mergeObjects 见到 undefined 是跳过,等于「这一层没表态」。第二套里不是。fixtures.ts 的 _appendFixtureList() 有段注释写得很直白:用 undefined 覆盖一个 option,意思是把它设回配置里的默认值、或者该 option 最初声明处的默认值。代码就是顺着 super 链往上找:遇到配置层那次覆盖(注册上带 optionOverride 标记)就停,否则一路找到最原始的那次注册,再取它的 fn。
对应到文档 docs/src/test-use-options-js.md 的 Reset an option 一节,写法是这样的:
test.describe(() => {
// Reset the value to a config-defined one.
test.use({ baseURL: undefined });
test('can navigate to intro from the home page', async ({ page }) => {
// This test will use "https://playwright.dev" base url as defined in the config.
});
});
注意它是退回配置里的值,不是清空。文档紧接着给了真要清空时的长写法:
test.use({
baseURL: [async ({}, use) => use(undefined), { scope: 'test' }],
});
一条例外:repeatEach
前面说顶层选项都走 takeFirst(命令行, project, 全局, 兜底),但 repeatEach 那一行里没有命令行这一项:
repeatEach: takeFirst(projectConfig.repeatEach, config.repeatEach, 1),
上面挂着注释,说是否套用命令行的 repeatEach 取决于该 project 是顶层还是被依赖的,并指向 loadUtils 里的 collectProjectsAndTestFiles。去 packages/playwright/src/runner/loadUtils.ts 里能找到那一行,它在遍历 project 闭包时只对 type === 'top-level' 的 project 才把命令行的值补上。也就是说,--repeat-each 不会传染给作为依赖跑的 setup project。workers 也是分两层的:全局那份限制的是总的 worker 进程数,project 上的 workers(docs/src/test-api/class-testproject.md 标注 since: v1.52)只额外压低该 project 用的 worker 数。
排查时按这个顺序看
真遇到「这个值怎么不对」,按下面顺序翻,基本一遍就能定位:
- 这个选项是在
use里,还是顶层字段?use走mergeObjects,顶层走takeFirst,两者链条不同。 - 有没有
test.use()?有的话它压过配置和命令行,从内层describe往外找第一个设了这个键的地方。 - 命令行只可能影响
trace和headless这两个use键,别的都别怀疑它。 - 值是
undefined吗?在配置合并层等于没写,在test.use()里等于「退回配置值」。 - 报了
Cannot use(...) in a describe group就说明这个选项是 worker 作用域的,得往上提。
还有一路容易被忘掉:仓库示例配置里 forbidOnly: !!process.env.CI、retries: process.env.CI ? 2 : 0、workers: process.env.CI ? 1 : undefined 都读环境变量(这几个值是 docs/src/test-configuration-js.md 里的示例值,不是硬性推荐)。本地和 CI 上表现不一致时,先看是不是这条分支翻了。Windows 下临时设环境变量的写法和 Linux/macOS 不同:PowerShell 里是 $env:CI="1",cmd 里是 set CI=1,Linux/macOS 的 shell 里是 CI=1 npx playwright test 这种前缀写法。这一条属于各平台 shell 的通用做法,不是 Playwright 官方文档内容,写脚本前请自行确认。
以上代码片段均为仓库文件原文摘录;文中提到的合并顺序结论,是按仓库代码中的接口语义整理的,未经实测,以仓库最新代码为准。该项目持续更新,上述文件路径、函数名与字段名随版本变动,请以仓库最新内容为准。
本文依据 github.com/microsoft/playwright 仓库于 2026-08-18 的公开内容整理,
事实来自仓库内的文档与源码。我们没有对文中涉及的功能做过实测,
因此不涉及运行速度、稳定性与实际表现的任何描述。
该项目迭代频繁,文中涉及的 API 签名、配置项与默认值随版本变动,请以仓库最新内容为准。
本文不涉及浏览器版本矩阵与版本清单,相关信息请以官方发布说明为准。