Playwright 的 TypeScript 类型报错:fixture 扩展后类型对不上
写自定义 fixture 的人迟早会撞上这一幕:npx playwright test 跑得好好的,编辑器却在 async ({ myFixture }) 这一行划红线,说这个属性不存在;或者把一个 worker 级别的 fixture 写成普通的 async 函数,类型立刻不匹配;再或者在 playwright.config.ts 的 use 里填自定义 option,被告知对象字面量里不该有这个键。
这三种现象看着不相干,落回仓库里其实是同一串东西:test.extend 的两个泛型参数、Fixtures 这个映射类型的分支,以及 Playwright 对 tsconfig.json 只认几个选项这件事。
第一步:先确认这是不是类型层面的问题
docs/src/test-typescript-js.md 开篇就写明了一个容易被忽略的事实:Playwright 不做类型检查,即便存在非致命的 TypeScript 编译错误,测试也照跑。文档给出的建议是让 TypeScript 编译器与 Playwright 并行跑,本地开发用 watch 模式:
npx tsc -p tsconfig.json --noEmit -w
CI 上则是文档里那段 GitHub Actions 片段的写法,先跑类型检查再跑测试:
- name: Run type checks
run: npx tsc -p tsconfig.json --noEmit
- name: Run Playwright tests
run: npx playwright test
判定动作就落在这里:单独跑一次 npx tsc -p tsconfig.json --noEmit。如果它报错而 npx playwright test 不报,说明问题纯粹在类型层,测试逻辑没坏;反过来,如果 tsc 干净而失败发生在运行期,那本文这条线索就不适用了。这条命令在 Windows 的 PowerShell、cmd 与 macOS/Linux 的 bash 里写法一致(此为通用做法,非项目官方内容)。
第二步:看两个泛型参数有没有放错位置
packages/playwright/types/test.d.ts 里 TestType 上的 extend 签名是这一行:
extend<T extends {}, W extends {} = {}>(fixtures: Fixtures<T, W, TestArgs, WorkerArgs>): TestType<TestArgs & T, WorkerArgs & W>;
两件事从签名里直接读得出来。一是它返回一个新的 TestType,参数类型是原来的 TestArgs 与你新增的 T 求交集——所以扩展之后必须从你自己那个模块导入 test,docs/src/test-fixtures-js.md 的示例正是 import { test } from './my-test';如果测试文件里仍然从 @playwright/test 直接导入 test,拿到的还是没有你那些 fixture 的那份类型。二是 W 有默认值 {},写单个泛型时它就是空的,也就是说只写一个泛型参数等于声明了一组 test-scoped fixture。
worker 级别的 fixture 要放在第二个位置。文档里那个 account 的例子写成:
export const test = base.extend<{}, { account: Account }>({
第一个泛型专门留了个 {}。这不是排版习惯,把 account 挪到第一个位置,类型上它就变成 test-scoped 了。
更细一层在 Fixtures 这个映射类型上。同一个文件里,新增 worker fixture 与新增 test fixture 走的是两个不同的分支:
} & {
[K in Exclude<keyof W, keyof PW | keyof PT>]?: [WorkerFixtureValue<W[K], W & PW>, { scope: 'worker', auto?: boolean, option?: boolean, timeout?: number | undefined, title?: string, box?: boolean | 'self' }];
} & {
[K in Exclude<keyof T, keyof PW | keyof PT>]?: TestFixtureValue<T[K], T & W & PT & PW> | [TestFixtureValue<T[K], T & W & PT & PW>, { scope?: 'test', auto?: boolean, option?: boolean, timeout?: number | undefined, title?: string, box?: boolean | 'self' }];
};
差别一眼可见:test fixture 那一支是联合类型,裸的函数和元组都收;worker fixture 那一支只有元组形式,没有裸函数这个选项。所以一个声明在 W 里的 fixture,你写成 myFixture: async ({}, use) => {} 一定不过类型,必须补上 { scope: 'worker' } 这半截。文档里那个 account 也确实是元组写法,并在正文中说明要传 {scope: 'worker'} 才会每个 worker 只建立一次。
顺带一提元组第二项里的字段名:scope、auto、option、timeout、title、box 都在类型里,docs/src/test-fixtures-js.md 分别有对应的用法段落。
还有一种情况是覆盖已有的 fixture(比如重写内置的 page)。Fixtures 里覆盖父级 fixture 走的是另外两个分支,[K in keyof PW] 与 [K in keyof PT],它们的元组第二项里只列了 scope、timeout、title、box 四个键,没有 auto,也没有 option。换句话说,想给一个覆盖项加上 option: true,类型这一关就过不去;option 只能声明在新增的那一组里。这两个分支都允许裸函数,也允许元组;但一旦用元组,scope 是必填且取值写死的——覆盖父级 worker fixture 只能写 'worker',覆盖父级 test fixture 只能写 'test'。对比之下,新增 test fixture 那一支的元组里 scope 是可选的。这些差异不需要靠猜,四个分支就并排摆在同一个类型定义里,对着读一遍比试错快。
第三步:类型声明怎么合并才不打架
docs/src/test-api/class-test.md 记载 Test.extend 自 v1.10 起可用。docs/src/test-fixtures-js.md 里那个带 option 的示例把 option 与 fixture 的类型分开声明再交叉:
export type MyOptions = {
defaultItem: string;
};
type MyFixtures = {
todoPage: TodoPage;
};
export const test = base.extend<MyOptions & MyFixtures>({
defaultItem: ['Something nice', { option: true }],
todoPage: async ({ page, defaultItem }, use) => {
// ...
},
});
'Something nice' 是仓库示例里的值,换成你自己的即可。这里的关键是 MyOptions 被 export 了出去,配置文件那边再 import type { MyOptions } from './my-test' 并写成 defineConfig<MyOptions>({...})——这一步漏掉,use: { defaultItem: 'Buy milk' } 就会因为 defineConfig 走了无泛型的那个重载而报错。类型定义文件里 defineConfig 一共有六个重载,分别覆盖无泛型、一个泛型、两个泛型三种形态各自的单参与多参版本。
还有一个坑藏在类型里,不看源不会知道:
type CustomProperties<T> = ExcludeProps<T, PlaywrightTestOptions & PlaywrightWorkerOptions & PlaywrightTestArgs & PlaywrightWorkerArgs>;
PlaywrightTestConfig<TestArgs, WorkerArgs> 用的是 CustomProperties<TestArgs>,而它会把与内置选项、内置参数重名的键排除掉。也就是说,自定义 option 一旦和内置项撞名,配置里那一项的类型走的是内置那份,你以为传下去的是自己的类型,实际不是。改个名是最省事的处置。另外 docs/src/test-fixtures-js.md 有一条命名约束:自定义 fixture 名要以字母或下划线开头,只能包含字母、数字和下划线。
多个模块各有各的 fixture 时,合并用 mergeTests:
import { mergeTests } from '@playwright/test';
import { test as dbTest } from 'database-test-utils';
import { test as a11yTest } from 'a11y-test-utils';
export const test = mergeTests(dbTest, a11yTest);
类型这边它是靠一组递归条件类型把每个 TestType 的两个泛型分别取出来再求交集实现的(MergedT、MergedW、MergedTestType)。这意味着合并的每一项都必须本身是 TestType,把一个普通对象塞进去不会有合理的结果。断言侧另有 mergeExpects,docs/src/test-assertions-js.md 里两者是配套出现的。
以上为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。
第四步:tsconfig 这一侧的支持范围
如果报错的是模块解析——路径别名在 tsc 下认、在 Playwright 里找不到,或者反过来——那就得看 docs/src/test-typescript-js.md 的这句:Playwright 只支持 allowJs、baseUrl、paths、references、extends 这几个 tsconfig 选项。这句话和「tsc 会完整读取整个 tsconfig」放在一起,就解释了两边行为不一致的来源:你在 tsconfig 里配的其它选项,tsc 认,Playwright 的加载器不看。
路径映射本身是支持的,文档给的示例是:
{
"compilerOptions": {
"paths": {
"@myhelper/*": ["packages/myhelper/*"] // This mapping is relative to the tsconfig.
}
}
}
注释是仓库原文,映射相对 tsconfig 所在位置解析。默认情况下 Playwright 会为每个被导入的文件沿目录向上找最近的 tsconfig.json 或 jsconfig.json,所以 tests/tsconfig.json 会自动生效于测试目录。想统一指定一份,命令行是 npx playwright test --tsconfig=tsconfig.test.json,配置文件里对应的字段是 tsconfig,docs/src/test-api/class-testconfig.md 记载它自 v1.49 起可用。
这里有个边界必须记住:文档写明配置文件里的 tsconfig 字段在加载配置文件本身及其依赖时不生效,并且命令行给了 --tsconfig 时该字段被忽略。所以如果你的 fixtures 文件是被 playwright.config.ts 直接 import 的,靠这个字段生效的路径映射对它就不管用。
Windows 这边额外提一句通用做法(非项目官方内容):本机文件系统大小写不敏感,导入路径里大小写写错在本地能过,推到 Linux 的 CI 上才会炸;paths 里也建议统一写正斜杠。
第五步:什么情况说明不是这个原因
tsc --noEmit干净,失败在运行期。类型是编译期的事,只在运行期暴露的问题不在本文这条线上。- 报错来自 TypeScript 的新特性或实验特性。
docs/src/test-typescript-js.md明说,用到实验性或很新的 TypeScript 特性时 Playwright 可能无法正确转换你的代码,处置方式是先自行编译再交给 Playwright:pretest脚本跑tsc --incremental -p tests/tsconfig.json,test脚本跑playwright test -c tests-out,-c指向编译产物目录。这是另一条路,不是改泛型能解决的。 - fixture 名不合法。名字不满足上面那条命名约束时,问题不在泛型摆放。
- 压根没有把扩展后的
test导出/导入。这时候类型上就是 base 的那一份,检查导入来源比改声明有用。
该项目持续更新,上面涉及的签名、字段与文档措辞随版本变动,以仓库最新内容为准。
本文依据 github.com/microsoft/playwright 仓库于 2026-08-18 的公开内容整理,
事实来自仓库内的文档与源码。我们没有对文中涉及的功能做过实测,
因此不涉及运行速度、稳定性与实际表现的任何描述。
该项目迭代频繁,文中涉及的 API 签名、配置项与默认值随版本变动,请以仓库最新内容为准。
本文不涉及浏览器版本矩阵与版本清单,相关信息请以官方发布说明为准。