Playwright TestInfo 里能拿到什么:把用例上下文用起来
用例在 CI 上挂了,报告里只有一行 expect(received).toHaveText(expected)。你知道当时后端返回了什么、知道自己在代码里打了日志,但那些东西都留在了 worker 进程的 stdout 里,翻不到。这时候你需要的那个对象叫 TestInfo。
它的文档在仓库的 docs/src/test-api/class-testinfo.md。开头那几行元信息值得先看一眼:* since: v1.10、* langs: js。第二行是硬边界——这个类的文档只标了 js 这一种语言绑定,我们在 docs/src/test-api/ 下没有找到 Python / Java / .NET 对应的 TestInfo 页面。所以下面讲的一切属于 @playwright/test 这个 JS 测试运行器,不要往其它绑定上套。
两种把它拿到手的方式
第一种是测试函数的第二个参数。文档开头那个例子逐字是这样:
import { test, expect } from '@playwright/test';
test('basic test', async ({ page }, testInfo) => {
expect(testInfo.title).toBe('basic test');
await page.screenshot(testInfo.outputPath('screenshot.png'));
});
注意第一个参数是 fixture 的解构对象,testInfo 是第二个参数。很多人写了半年 Playwright 都没用过它,因为解构写法把它挡在了后面。
第二种是 test.info()。docs/src/test-api/class-test.md 里 method: Test.info 写明它「只能在测试执行期间调用,否则会抛错」。这句话在源码里能对上:packages/playwright/src/common/testType.ts 里的实现取 currentTestInfo(),取不到就抛 test.info() can only be called while test is running。所以你在模块顶层、在 playwright.config.ts 里、在一个纯工具函数被非测试路径调用时,test.info() 会直接炸——这不是 bug,是它的语义。
字段大致分四组
翻文档的时候按用途分组会比按字母序好记:
| 组 | 成员 | 备注 |
|---|---|---|
| 身份 | title、titlePath、file、line、column、testId、fn、annotations、tags | titlePath 是从测试文件名开始的完整标题路径;tags 标注 since: v1.43 |
| 结果 | status、expectedStatus、error、errors、duration | error 等于 errors 的第一个元素 |
| 并发 | workerIndex、parallelIndex、repeatEachIndex、retry | 前两个另有环境变量 TEST_WORKER_INDEX、TEST_PARALLEL_INDEX |
| 路径与控制 | outputDir、outputPath()、snapshotDir、snapshotPath()、timeout、setTimeout()、skip()、fixme()、fail()、slow() | 后四个的条件版本都接受 condition 与可选的 description |
有几处细节文档写得很直白,但容易被跳过:
duration 那条写明「测试结束之前永远是零」,并建议在 afterEach 里用。status 那条写明「测试结束后在 afterEach 钩子和 fixture 里可用」,类型是可选的 TestStatus。这两句合起来的意思是:你不能在测试跑到一半的时候读这两个字段来做判断。
workerIndex 与 parallelIndex 的区别也在文档里说清了:parallelIndex 的取值在 0 到 workers - 1 之间,worker 重启后新进程沿用同一个 parallelIndex;而 workerIndex 在 worker 重启后会拿到一个新的唯一值。要给每个并发槽位分配固定资源(端口、账号、数据库 schema)用前者,要区分「这是第几次起的进程」用后者。
snapshotSuffix 那一条挂着一个 note,逐字写着不建议使用(discouraged),推荐改用 TestConfig.snapshotPathTemplate。tags 那一条也挂了 note:测试运行中对这个数组做的修改,reporter 看不到。
annotations 的元素结构是 type、可选的 description、可选的 location,文档写明这个列表把三处来源合在一起:用例自身的注解、它所属的每一个 test.describe 组的注解、以及该测试文件的文件级注解。想在报告里按「这批用例被标了什么」做分类,读这个字段比自己解析标题靠谱。
repeatEachIndex 只有在 repeat each 模式下才有区分度,文档写明这个模式由命令行的 --repeat-each 启用。config 与 project 两个字段拿到的是处理过的配置对象(FullConfig 与 FullProject),也就是说合并完项目覆盖之后的结果,不是你配置文件里那份原始字面量。
改运行方式的那几个方法
TestInfo 上还挂着一组会改变当前用例命运的方法,它们和 test.skip / test.fail / test.fixme / test.slow 是对应关系,区别在于这一组是在运行中、拿着上下文再决定:
setTimeout(timeout)改当前用例的超时,文档写明传 0 表示不设超时。典型写法是在钩子里做加法,class-testinfo.md里那个例子是testInfo.setTimeout(testInfo.timeout + 30000),30000是仓库示例里的具体值,不是什么推荐值。skip()与fixme()会立即中止当前用例,文档对这两个都用了 immediately aborted 这个说法。所以别指望它们后面的清理代码还会跑。fail()把用例标成「应该失败」,文档写明运行器会真的跑一遍并确认它确实失败了——也就是说这个用例意外通过时反而算不正常。slow()文档写明给它「默认超时的三倍」。
后三个都各有一个带 condition 与可选 description 的重载,description 会体现在测试报告里。用 skip() 之后 expectedStatus 会变成 'skipped',用 fail() 之后变成 'failed'——这正是 expectedStatus 那一条列出的两个例外情况,也是为什么判断「这个用例是不是按预期跑完的」要拿 status 和 expectedStatus 比,而不是直接看 status === 'passed'。
attach 到底做了什么
TestInfo.attach 的签名是 attach(name, options),options 里 body 与 path 互斥,另有 contentType。文档里附截图的例子是:
test('basic test', async ({ page }, testInfo) => {
await page.goto('https://playwright.dev');
const screenshot = await page.screenshot();
await testInfo.attach('screenshot', { body: screenshot, contentType: 'image/png' });
});
contentType 省略时的行为文档写明了:优先从 path 推断,字符串附件回落到 text/plain,Buffer 附件回落到 application/octet-stream。这是仓库当前文档里的默认行为,随版本可能变动。
更有意思的是实现。packages/playwright/src/worker/testInfo.ts 里 attach() 先开一个 category 为 'test.attach' 的 step,再把 options 交给 normalizeAndSaveAttachment(this.outputPath(), name, options)。那个函数在 packages/playwright/src/util.ts:
- 走
path分支时,它对options.path调calculateSha1,把文件复制到outputPath下的attachments子目录,目标文件名是「name经sanitizeForFilePath处理后加连字符 + 那个 hash + 原扩展名」。这解释了文档里那个 note 说的「attach 会自动把文件复制到 reporter 能访问的位置,await 之后你可以安全删掉原文件」——因为它确实复制了一份。文档里name参数那段也说了,名字会被 sanitize 并用作落盘文件名的前缀。 - 走
body分支时不落盘,字符串会被Buffer.from转成 Buffer。
这里有一处文档与代码值得并排看:文档写「path 与 body 必须指定其一,且不能同时指定」,而 normalizeAndSaveAttachment 里只有「两个都传」才抛 Exactly one of "path" and "body" must be specified;两个都不传时它直接返回 { name, contentType: 'text/plain' },也就是一个没有内容的附件。两处的严格程度不一样,写代码时别指望「忘了传内容」会有错误提示。
attachments 数组能不能直接 push
TestInfo.attachments 那一条明确写着:要加附件请用 TestInfo.attach,不要直接往这个数组里 push。但翻到 docs/src/test-fixtures-js.md 的自动 fixture 例子,官方自己写的恰恰是 push:
if (testInfo.status !== testInfo.expectedStatus) {
const logFile = testInfo.outputPath('logs.txt');
await fs.promises.writeFile(logFile, logs.join('\n'), 'utf8');
testInfo.attachments.push({ name: 'logs', contentType: 'text/plain', path: logFile });
}
这两处对不上,答案在 worker/testInfo.ts 的构造函数里:它先用 _attachmentsPush 存下原生 push,然后用 Object.defineProperty 把 attachments.push 替换成一个会逐个走 _attach 的实现。所以 push 进去的东西同样会进 trace、同样会触发 onAttach 回调,不会被 reporter 漏掉。两者真正的差别是:attach() 会先跑 normalizeAndSaveAttachment(复制文件、推断 contentType),push 不会——你 push 的那个 path 得自己保证文件还在、且位置是 reporter 够得着的。上面这个例子先把日志写进 outputPath('logs.txt'),正好满足了这个前提。
顺带说 outputPath():源码里它先算路径再 mkdirSync(this.outputDir, { recursive: true }),然后用 getContainedPath 校验结果是否还在 outputDir 里,越界就抛 The outputPath is not allowed outside of the parent directory. Please fix the defined path.。文档里也写了同样的约束。Windows 上尤其容易撞这条:习惯性传一个 C:\temp\... 这样的绝对路径进去,它不会帮你拼,会直接判越界。要落到测试目录之外,就别走 outputPath(),自己用 path 模块拼。另外,用 testInfo.title 拼文件名前先自己清洗一遍非法字符——这是通用做法,不是仓库里写明的官方建议。
在 fixture 里什么时候读
base.extend() 的 fixture 函数第三个参数就是这个上下文对象,但test 作用域与 worker 作用域拿到的不是同一个东西。
docs/src/test-api/class-workerinfo.md 第一句写明:WorkerInfo 是 TestInfo 的一个子集,提供给 worker 作用域的 fixture。它只有 config、parallelIndex、project、workerIndex 四个属性。test-fixtures-js.md 里那个 account 的 worker fixture 例子,第三个参数名就叫 workerInfo,用的正是 workerInfo.workerIndex。
结论很直接:想读 title、status、retry、outputPath() 的逻辑,不能放进 worker 作用域的 fixture,那里根本没有这些字段。
时机上还有两条:
retry 在任何 hook 和 fixture 里都读得到——class-testinfo.md 里 retry 的例子就写在 test.beforeEach 中,注释逐字是「You can access testInfo.retry in any hook or fixture」。首跑是 0,第一次重试是 1。
而 status / duration 只有在测试跑完之后才有意义,落到 fixture 里就是 await use() 之后的那段代码。上面那个 saveLogs 例子的结构正是如此:use() 之前装日志收集器,use() 之后比较 status 与 expectedStatus,不一致才落盘并挂附件。
顺便,fixture 的 box: true 与 title: 'my fixture' 这两个选项在 test-fixtures-js.md 里的示例也带着第三个 testInfo 参数,前者用于把这个 fixture 的 step 从报告里藏掉,还可以写成 box: 'self' 只藏 fixture 自身、保留内部的 step。
一个组合起来的写法
import { test as base } from '@playwright/test';
export const test = base.extend<{ apiTrace: void }>({
apiTrace: [async ({}, use, testInfo) => {
const records = [];
await use();
if (testInfo.status !== testInfo.expectedStatus) {
await testInfo.attach(`api-${testInfo.retry}`, {
body: JSON.stringify(records, null, 2),
contentType: 'application/json',
});
}
}, { auto: true }],
});
以上为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。
最后提一句 snapshotPath()。它的 kind 选项标注 since: v1.53,取值是 "snapshot" | "screenshot" | "aria" 三个,分别对应 toMatchSnapshot、toHaveScreenshot、toMatchAriaSnapshot 三类断言的路径模板;源码里 snapshotPath 遇到别的值会抛 testInfo.snapshotPath: unknown kind。文档还提醒 TestInfo.snapshotDir 这个属性不考虑 snapshotPathTemplate 配置——想拿到断言真正会去读的那个路径,用 snapshotPath(),不要自己拿 snapshotDir 去拼。
本文依据 github.com/microsoft/playwright 仓库于 2026-08-18 的公开内容整理,
事实来自仓库内的文档与源码。我们没有对文中涉及的功能做过实测,
因此不涉及运行速度、稳定性与实际表现的任何描述。
该项目迭代频繁,文中涉及的 API 签名、配置项与默认值随版本变动,请以仓库最新内容为准。
本文不涉及浏览器版本矩阵与版本清单,相关信息请以官方发布说明为准。