自定义 fixture:把 Playwright 用例里的重复准备工作抽出去
一、你现在大概是这样写的
一个测试文件写到第八个用例的时候,test.beforeEach 里通常已经塞了一串东西:new 一个页面对象、跳到某个 URL、灌两条初始数据。test.afterEach 里再把这些数据删掉。等到第二个测试文件也要同样的环境,最省事的做法就是复制粘贴——然后两边慢慢开始不一致。
Playwright 仓库的 docs/src/test-fixtures-js.md 里专门用「Without fixtures / With fixtures」两节做了这个对照。前一节的示例正是上面这个形态:test.describe 包一层,外面声明 let todoPage,beforeEach 里 new TodoPage(page) 再 goto()、addToDo('item1'),afterEach 里 removeAll()。
文档自述 fixture 相对 before/after 钩子的几点差别,我照原意转述:setup 与 teardown 被封装在同一处;fixture 可以跨测试文件复用;fixture 是按需的,只有测试真的用到才会被建立;fixture 之间可以互相依赖。这几条是仓库文档自己写的理由,不是我替作者解释的。
对我们来说,最直接的收益是第一条和第三条:清理逻辑不再离创建逻辑十几行远,以及一个测试不写这个 fixture 名,它就不会被建起来。
二、前置条件:先确认你在哪条线上
这一步别跳。
1. 得是 Playwright Test(Test Runner),不是把 Playwright 当库调。 test.extend 来自 @playwright/test,仓库里所有相关示例的第一行都是 import { test as base } from '@playwright/test';。
2. 这是 JS/TS 绑定的写法。 docs/src/api/ 与 docs/src/test-api/ 下,fixture 的类文档是 docs/src/test-api/class-fixtures.md,文件头部三行元信息里明确标着 langs: js。而指南页在 docs/src/ 下只有 test-fixtures-js.md 这一个带语言后缀的版本,没有对应的不带后缀的共用页,也没有其它语言后缀的同名页。所以本文讲的这套写法只对 JS/TS 绑定成立,不要把它套到其它语言绑定上——那些绑定各自的组织方式,我们在仓库里没有找到与本页对应的说明,这里不猜。
3. 版本。 docs/src/test-api/class-test.md 里 ## method: Test.extend 一节标注 * since: v1.10;class-fixtures.md 整个类同样标注 * since: v1.10。也就是说这不是新特性。该项目持续更新,具体签名与选项以仓库最新内容为准。
4. 命名有约束。 test-fixtures-js.md 有一条 note:自定义 fixture 名应以字母或下划线开头,只能包含字母、数字和下划线。
三、写法拆开看
3.1 test.extend 返回的是一个新的 test
仓库示例里的关键点是 import { test as base }——把内置的 test 改名成 base,扩展之后再导出一个新的 test:
import { test as base } from '@playwright/test';
import { TodoPage } from './todo-page';
import { SettingsPage } from './settings-page';
// Declare the types of your fixtures.
type MyFixtures = {
todoPage: TodoPage;
settingsPage: SettingsPage;
};
// Extend base test by providing "todoPage" and "settingsPage".
// This new "test" can be used in multiple test files, and each of them will get the fixtures.
export const test = base.extend<MyFixtures>({
todoPage: async ({ page }, use) => {
// Set up the fixture.
const todoPage = new TodoPage(page);
await todoPage.goto();
await todoPage.addToDo('item1');
await todoPage.addToDo('item2');
// Use the fixture value in the test.
await use(todoPage);
// Clean up the fixture.
await todoPage.removeAll();
},
settingsPage: async ({ page }, use) => {
await use(new SettingsPage(page));
},
});
export { expect } from '@playwright/test';
最后那行 export { expect } from '@playwright/test'; 在仓库多个示例里都有,别漏——测试文件从 ./my-test 一起导入 test 和 expect,就不用再引两个来源。
TypeScript 下的泛型参数 <MyFixtures> 是给类型检查用的:Test.extend 的参数在 class-test.md 里定义为 fixtures <[Object]>,一个包含 fixture 与 option 的对象。
3.2 第一个参数必须是对象解构,这是硬性的
fixture 函数的第一个参数写成 { page }、{ page, defaultItem } 这种形式,看起来只是习惯,其实是被强制的。packages/playwright/src/common/fixtures.ts 里的 fixtureParameterNames() 会把你的函数 toString() 之后用正则抠出第一个参数的源文本,逐个拆出属性名,以此确定这个 fixture 依赖哪些别的 fixture。
由此派生两条会直接报错的写法,错误信息就在这个文件里:
- 第一个参数不是
{...}包起来的:First argument must use the object destructuring pattern:,后面跟上你写的那段参数文本。 - 用了 rest 属性:
Rest property "..." is not supported. List all used fixtures explicitly, separated by comma.——必须把用到的 fixture 一个个列全,不能...rest一把抓。
知道依赖是这么解析出来的,就能理解为什么「fixture 里想用什么,就得在第一个参数里写出来」不是风格建议而是机制要求。
3.3 use() 前后各干什么
这是本文的正题。test-fixtures-js.md 的 Execution order 一节写得很直白:每个 fixture 在 await use() 调用的前后各有一个 setup 阶段和 teardown 阶段。setup 在需要它的测试或钩子运行之前执行;teardown 在这个 fixture 不再被任何测试或钩子使用时执行。
对应到代码里,packages/playwright/src/worker/fixtureRunner.ts 的做法是:框架把一个 useFunc 传给你的 fixture 函数当第二个参数,你调用 use(value) 时它把 value 记下来、放行等待中的测试,然后卡在那里不返回;等到该拆的时候再让它返回,于是 use() 之后那几行才开始跑。所以「setup / teardown」在源码里就是同一个 async 函数被 use() 切成的前后两段,不是两个回调。
文档给的三条排序规则:
- A 依赖 B 时,B 总是先于 A setup、后于 A teardown。
- 非 automatic 的 fixture 是惰性的,只有测试或钩子真的需要才会建立。
- test 作用域的 fixture 每个测试之后拆除;worker 作用域的只在 worker 进程结束时拆除。
3.4 加选项要换成元组写法
fixture 值写成 [fn, options] 的元组,就能带选项。fixtures.ts 里的 FixtureOptions 类型列出的字段是 auto、scope、option、timeout、title、box。仓库文档分别给了用法:
{ scope: 'worker' }——一个 worker 进程建一次,示例里用它做跨测试共享的account。{ auto: true }——测试没写这个名字也照样建立,文档用它做「失败时自动附加调试日志」和「全局 beforeEach/afterEach」。{ option: true }——声明成可在配置文件use里设置的选项,例如defaultItem: ['Something nice', { option: true }]('Something nice'是仓库示例里的默认值,只是示例)。{ timeout: 60000 }——给慢 fixture 单独的超时,文档说明 fixture 的 setup/teardown 时间计入测试超时,这里的60000同样是仓库示例里的值。{ box: true }/{ box: 'self' }——把这个 fixture 从报告和 UI mode 的步骤里藏起来;'self'只藏它自己,内部步骤仍然显示。{ title: 'my fixture' }——在报告和错误信息里用自定义标题代替 fixture 名。
源码里没写选项时的取值是 scope: 'test'、auto: false、option: false、timeout: undefined。这是仓库当前代码里的默认值,随版本可能变动。
以上为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。
四、边界:这些地方会直接失败
use() 只能调一次。 fixtureRunner.ts 里有个 called 标记,第二次调用抛 Cannot provide fixture value for the second time。
use() 必须调。 你的 fixture 函数跑完却没调过 use(),会抛 use() was not called in fixture "<name>"。这条最容易在提前 return 或者忘了 await 的时候撞上。
worker 作用域不能在 describe 组里 use。 fixtures.ts 的加载检查会报 Cannot use({ ... }) in a describe group, because it forces a new worker.,提示把它放到测试文件顶层或配置文件里。
同名重复注册时,scope / auto / option 三项必须一致。 不一致会在加载阶段报出「已经被注册为 { scope: '...' }」这类错误。这意味着覆盖一个已有 fixture 时,只能换实现,不能顺手改它的作用域。
只有 { option: true } 的才能在配置里设值。 否则报 Fixture "<key>" cannot be overridden in the configuration "use" section.。
option 值是数组时要多包一层。 文档专门标了 CORRECT / WRONG 两段:test.use({ persons: [actualPersons, { scope: 'test' }] }) 是对的,直接 persons: actualPersons 不生效——因为元组写法本身就占用了外层数组。
fixture 引用自身且没有基础实现会报「references itself, but does not have a base implementation」。覆盖内置 page 时之所以可以在参数里写 { page },是因为存在被覆盖的那个基础实现。
至于「fixture 抛异常时依赖链上各段的拆除边界具体怎么走」,仓库里除了上面那几条排序规则之外,我们没有找到更细的成文说明,这里不做推断。
五、怎么确认自己配对了
第一,看它有没有被建起来。 文档在 Execution order 一节列了一个带 unusedFixture 的例子,并明说:unusedFixture 从不会被 setup,因为没有任何测试或钩子用到它。所以最简单的自检就是把 fixture 名从测试参数里去掉,看行为是否随之消失——不消失说明你写成了 { auto: true },或者它被别的 fixture 依赖着。
第二,看报告里的步骤。 文档说自定义 fixture 默认会作为独立步骤出现在 UI mode、Trace Viewer 和各类测试报告里,也会出现在 runner 的错误信息中。这正好是确认 setup 与 teardown 两段都执行过的现成手段。反过来,如果你给它加了 { box: true },就是主动放弃了这个可见性——噪音少了,排查时也少了一条线索。
第三,验依赖顺序。 按 3.3 的规则,B 被 A 依赖时必须先于 A 出现、后于 A 结束。在两段里各加一行日志,对着报告里的顺序看一遍就够。
第四,Windows 侧注意落盘路径。 fixture 里如果要写临时文件(比如 auto fixture 收集日志),别自己拼路径。仓库示例用的是 testInfo.outputPath('logs.txt'),docs/src/test-api/class-testinfo.md 写明它返回 outputDir 内的一个路径,并保证并行运行的测试互不干扰;同时警告路径必须留在该测试的 outputDir 之内,否则会抛错。Windows 上手写反斜杠或者混用分隔符是老坑,而 outputPath('dir', 'temporary-file.txt') 这种按段传参的用法在 Windows 与 Linux/macOS 上是同一套写法,交给它拼比自己拼稳妥。
第五,多来源合并时用 mergeTests。 当 fixture 分散在几个模块里,packages/playwright/types/test.d.ts 里导出了 mergeTests,文档示例是 export const test = mergeTests(dbTest, a11yTest);。合并之后测试参数里能同时拿到两边的 fixture——这也是一种验证:名字都能解析出来,说明合对了。
本文依据 github.com/microsoft/playwright 仓库于 2026-08-18 的公开内容整理,
事实来自仓库内的文档与源码。我们没有对文中涉及的功能做过实测,
因此不涉及运行速度、稳定性与实际表现的任何描述。
该项目迭代频繁,文中涉及的 API 签名、配置项与默认值随版本变动,请以仓库最新内容为准。
本文不涉及浏览器版本矩阵与版本清单,相关信息请以官方发布说明为准。