Playwright TestInfo 里能拿到什么:把用例上下文用起来

2026-08-18

用例在 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.mdmethod: 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,是它的语义。

字段大致分四组

翻文档的时候按用途分组会比按字母序好记:

成员备注
身份titletitlePathfilelinecolumntestIdfnannotationstagstitlePath 是从测试文件名开始的完整标题路径;tags 标注 since: v1.43
结果statusexpectedStatuserrorerrorsdurationerror 等于 errors 的第一个元素
并发workerIndexparallelIndexrepeatEachIndexretry前两个另有环境变量 TEST_WORKER_INDEXTEST_PARALLEL_INDEX
路径与控制outputDiroutputPath()snapshotDirsnapshotPath()timeoutsetTimeout()skip()fixme()fail()slow()后四个的条件版本都接受 condition 与可选的 description

有几处细节文档写得很直白,但容易被跳过:

duration 那条写明「测试结束之前永远是零」,并建议在 afterEach 里用。status 那条写明「测试结束后在 afterEach 钩子和 fixture 里可用」,类型是可选的 TestStatus。这两句合起来的意思是:你不能在测试跑到一半的时候读这两个字段来做判断。

workerIndexparallelIndex 的区别也在文档里说清了:parallelIndex 的取值在 0workers - 1 之间,worker 重启后新进程沿用同一个 parallelIndex;而 workerIndex 在 worker 重启后会拿到一个新的唯一值。要给每个并发槽位分配固定资源(端口、账号、数据库 schema)用前者,要区分「这是第几次起的进程」用后者。

snapshotSuffix 那一条挂着一个 note,逐字写着不建议使用(discouraged),推荐改用 TestConfig.snapshotPathTemplatetags 那一条也挂了 note:测试运行中对这个数组做的修改,reporter 看不到。

annotations 的元素结构是 type、可选的 description、可选的 location,文档写明这个列表把三处来源合在一起:用例自身的注解、它所属的每一个 test.describe 组的注解、以及该测试文件的文件级注解。想在报告里按「这批用例被标了什么」做分类,读这个字段比自己解析标题靠谱。

repeatEachIndex 只有在 repeat each 模式下才有区分度,文档写明这个模式由命令行的 --repeat-each 启用。configproject 两个字段拿到的是处理过的配置对象(FullConfigFullProject),也就是说合并完项目覆盖之后的结果,不是你配置文件里那份原始字面量。

改运行方式的那几个方法

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 那一条列出的两个例外情况,也是为什么判断「这个用例是不是按预期跑完的」要拿 statusexpectedStatus 比,而不是直接看 status === 'passed'

attach 到底做了什么

TestInfo.attach 的签名是 attach(name, options)optionsbodypath 互斥,另有 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.tsattach() 先开一个 category 为 'test.attach' 的 step,再把 options 交给 normalizeAndSaveAttachment(this.outputPath(), name, options)。那个函数在 packages/playwright/src/util.ts

  • path 分支时,它对 options.pathcalculateSha1,把文件复制到 outputPath 下的 attachments 子目录,目标文件名是「namesanitizeForFilePath 处理后加连字符 + 那个 hash + 原扩展名」。这解释了文档里那个 note 说的「attach 会自动把文件复制到 reporter 能访问的位置,await 之后你可以安全删掉原文件」——因为它确实复制了一份。文档里 name 参数那段也说了,名字会被 sanitize 并用作落盘文件名的前缀。
  • body 分支时不落盘,字符串会被 Buffer.from 转成 Buffer。

这里有一处文档与代码值得并排看:文档写「pathbody 必须指定其一,且不能同时指定」,而 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.definePropertyattachments.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 第一句写明:WorkerInfoTestInfo 的一个子集,提供给 worker 作用域的 fixture。它只有 configparallelIndexprojectworkerIndex 四个属性。test-fixtures-js.md 里那个 account 的 worker fixture 例子,第三个参数名就叫 workerInfo,用的正是 workerInfo.workerIndex

结论很直接:想读 titlestatusretryoutputPath() 的逻辑,不能放进 worker 作用域的 fixture,那里根本没有这些字段。

时机上还有两条:

retry 在任何 hook 和 fixture 里都读得到——class-testinfo.mdretry 的例子就写在 test.beforeEach 中,注释逐字是「You can access testInfo.retry in any hook or fixture」。首跑是 0,第一次重试是 1

status / duration 只有在测试跑完之后才有意义,落到 fixture 里就是 await use() 之后的那段代码。上面那个 saveLogs 例子的结构正是如此:use() 之前装日志收集器,use() 之后比较 statusexpectedStatus,不一致才落盘并挂附件。

顺便,fixture 的 box: truetitle: '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" 三个,分别对应 toMatchSnapshottoHaveScreenshottoMatchAriaSnapshot 三类断言的路径模板;源码里 snapshotPath 遇到别的值会抛 testInfo.snapshotPath: unknown kind。文档还提醒 TestInfo.snapshotDir 这个属性不考虑 snapshotPathTemplate 配置——想拿到断言真正会去读的那个路径,用 snapshotPath(),不要自己拿 snapshotDir 去拼。


本文依据 github.com/microsoft/playwright 仓库于 2026-08-18 的公开内容整理, 事实来自仓库内的文档与源码。我们没有对文中涉及的功能做过实测, 因此不涉及运行速度、稳定性与实际表现的任何描述。 该项目迭代频繁,文中涉及的 API 签名、配置项与默认值随版本变动,请以仓库最新内容为准。 本文不涉及浏览器版本矩阵与版本清单,相关信息请以官方发布说明为准。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。