视觉快照换台机器就挂:Playwright 跨平台渲染差异怎么处理

2026-08-18

写视觉回归的人大概都遇到过这一幕:本机 toHaveScreenshot() 一路绿,推上去 CI 立刻红,报错还不是「像素差了多少」,而是压根说快照不存在。这类问题十有八九不在页面上,而在快照文件名上——文件名里带着平台标识,换台机器就对不上号了。

下面按「怎么确认 → 怎么处置 → 怎么验证 → 什么情况不是这个原因」走一遍,依据全部来自 github.com/microsoft/playwright 仓库里的文档与源码。

一、现象长什么样

docs/src/test-snapshots-js.md 里给了首次运行时 test runner 的原话:

Error: A snapshot doesn't exist at example.spec.ts-snapshots/example-test-1-chromium-darwin.png, writing actual.

注意这个文件名 example-test-1-chromium-darwin.png(仓库文档里的示例名)。文档拆解得很清楚:example-test-1.png 是自动生成的快照名,chromium-darwin 里的 chromium 是浏览器名或者项目名(配置里用了多个 project 时,这一段换成 project 名),darwin 是平台。文档同一节写明:不同浏览器、不同平台的渲染、字体等都有差异,所以你需要为它们准备各自的快照

于是典型症状就是两种:一是在新机器上跑,报「快照不存在」并顺手写了一份 actual;二是有人把别的平台生成的基线改了名字塞进来,跑成像素不匹配。

二、怎么确认是这个问题

三个可执行的动作,做完基本就定性了。

第一,直接看报错里的文件名尾巴。 把报错路径末段和你仓库里 <测试文件名>-snapshots/ 目录下已有的文件名逐字比。如果只差最后那一段平台标识,就是它。文档说明这个目录名以测试文件名开头,例如 my.spec.ts 会把快照放进 my.spec.ts-snapshots 目录。

第二,去源码确认这段后缀是谁加上去的。 packages/playwright/src/worker/testInfo.ts 里写着未配置模板时使用的默认模板:

{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{-snapshotSuffix}{ext}

模板本身没有平台 token,平台是从 {snapshotSuffix} 进来的。而 packages/playwright/src/index.ts_setupContextOptions fixture 里有一行 testInfo.snapshotSuffix = process.platform;。两处放在一起看就明白了:默认路径里那段平台标识,是 fixture 在测试启动时按当前进程平台填的,Windows 与 Linux、macOS 自然不是同一个值,文件名也就不是同一个。

顺带一提,docs/src/test-api/class-testinfo.mdTestInfo.snapshotSuffix 标为 discouraged,建议改用配置里的 snapshotPathTemplate 来控制快照路径——你要动这段后缀,就往模板那边动。

第三,看清这是不是 JS 才有的能力。 docs/src/api/class-pageassertions.mdtoHaveScreenshot 两个重载都标了 * langs: js,文档正文也写着「screenshot assertions only work with Playwright test runner」。所以这套快照路径机制讨论的是 JS 绑定下的 Playwright Test,别拿它去解释其它语言绑定里的失败。

三、仓库文档给出的处置

1. 先认下那条警告

docs/src/test-snapshots-js.md 顶上有一条 warning,说浏览器渲染会随宿主操作系统、版本、设置、硬件、供电方式(电池还是电源适配器)、headless 模式等因素变化,要想截图一致,就在生成基线的同一环境里跑测试。这句话是所有处置的前提:跨平台差异不是靠调参数抹平的,是靠把环境固定住。

2. 阈值选项:三个,各管各的

docs/src/api/params.md 里三个选项的语义分别是:

选项语义
maxDiffPixels可接受的不同像素个数;默认未设置,默认值可在 TestConfig.expect 里配
maxDiffPixelRatio不同像素占总像素的比例,取值在 01 之间;默认同样未设置
threshold同一位置像素在 YIQ 色彩空间上可接受的感知色差,零最严格、一最宽松

threshold 在仓库文档里写明默认是 0.2——这是仓库当前文档里的默认值,随版本可能变动。docs/src/test-snapshots-js.md 还写明像素比较用的是 pixelmatch 库;docs/src/test-api/class-testconfig.md 里也把这个默认值挂在 "pixelmatch" 比较器名下。

单测试里这样传(toHaveScreenshot({ maxDiffPixels: 100 }) 是仓库示例里的值,只是示例,不是推荐值):

import { test, expect } from '@playwright/test';

test('example test', async ({ page }) => {
  await page.goto('https://playwright.dev');
  await expect(page).toHaveScreenshot({ maxDiffPixels: 100 });
});

要全项目共享就写进配置的 expect.toHaveScreenshot。这里要提醒一句:调阈值只能吃掉抗锯齿这类小噪声,它不会让一份 darwin 的基线在别的平台上被找到——文件名对不上时根本走不到比较那一步

另外 stylePath 可以在截图时挂一张自定义样式表,文档里的说法是用来过滤动态或易变元素、提高截图确定性;仓库示例里的样式表内容是把 iframe 设成 visibility: hidden。而 animations 选项要看清是哪一份定义:docs/src/api/params.md 里有两个同名参数块,通用截图那份写明默认 "allow"toHaveScreenshot 的两个重载在 docs/src/api/class-pageassertions.md 里引用的是另一份 screenshot-option-animations-default-disabled,默认 "disabled"。设成 "disabled" 时会停掉 CSS 动画、CSS 过渡与 Web Animations,文档写明有限时长的动画会被快进到结束、无限动画会先取消回初始态再在截图后播放。以上是仓库当前文档里的默认值,随版本可能变动。

3. 路径模板:把平台显式写进去

真正对症的是 snapshotPathTemplatedocs/src/api/params.md 列了它支持的 token,其中 {platform} 的定义逐字写着「The value of process.platform」,另外还有 {arg}{ext}{projectName}{snapshotDir}{testDir}{testFileDir}{testFileBaseName}{testFileName}{testFilePath}{testName}。文档还写明:每个 token 前可以加一个字符,只有该 token 非空时这个字符才会被用上(默认模板里的 {-projectName} 就是这个写法);模板解析成相对路径时相对 configDir;正斜杠在任何平台都能当分隔符。

配置写法(仓库文档里的示例):

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',

  // Single template for all assertions
  snapshotPathTemplate: '{testDir}/__screenshots__/{testFilePath}/{arg}{ext}',

  // Assertion-specific templates
  expect: {
    toHaveScreenshot: {
      pathTemplate: '{testDir}/__screenshots__{/projectName}/{testFilePath}/{arg}{ext}',
    },
    toMatchAriaSnapshot: {
      pathTemplate: '{testDir}/__snapshots__/{testFilePath}/{arg}{ext}',
    },
  },
});

packages/playwright/src/worker/testInfo.ts 里的取值顺序也写得很直白:截图类先取 expect.toHaveScreenshot.pathTemplate,没有再取 snapshotPathTemplate,都没有才落回默认模板。所以想让快照跨平台共用一份,就在模板里去掉平台段;想让它按平台分目录,就把 {platform} 显式写进模板——这两种选择的差别,你在文件树上一眼就能看见,而不是藏在默认后缀里。

4. 把基线生成固定到容器里

docs/src/ci.md 的 Via Containers 一节自述了这么做的理由:在容器里跑 job 可以不污染宿主环境的依赖,并且在不同操作系统之间为截图/视觉回归测试提供一致的环境。仓库给的 GitHub Actions 片段是把 job 的 container.image 指向官方镜像并加 options: --user 1001

镜像本身在 docs/src/docker.md:镜像里包含 Playwright 浏览器与浏览器系统依赖,但不包含 Playwright 包本身,需要另行安装;镜像标签在文档里写作 mcr.microsoft.com/playwright:v<版本>-noble 这种形式(文档里版本位置是占位符,各语言绑定另有各自的镜像名)。文档同时给了两条推荐配置:建议加 --init 避免 PID=1 进程带来的僵尸进程问题;用 Chromium 时建议 --ipc=host,否则 Chromium 可能内存耗尽而崩溃。文档还有一条明确限定:这个镜像仅用于测试与开发用途,不建议用它访问不受信任的网站。

Windows 侧要额外注意两点。 一是你在 Windows 上直接 npx playwright test --update-snapshots 生成的基线,文件名里的平台段与容器里生成的不是同一个值,两边混着提交就会出现「同一个测试有两份基线、CI 只认其中一份」;把生成基线这件事整体挪进容器,或者在模板里显式区分平台,二选一,别在两套之间来回横跳。二是路径长度:packages/playwright/src/worker/testInfo.ts 里对输出文件路径做了更激进的截断,源码注释写明理由是避免撞上 Windows 文件系统的长度限制——测试标题很长、目录嵌套又深时,这一层截断是存在的,别把文件名当成标题的逐字映射。

四、处置后怎么验证

更新基线的命令,文档 docs/src/test-snapshots-js.md 里就是这一条:

npx playwright test --update-snapshots

docs/src/test-cli-js.md 的选项表把它的语义写全了:-u--update-snapshots [mode],可选值是 allchangedmissingnone不加这个 flag 时默认是 missing,加了 flag 但不给值时默认是 changed。同名的配置项 TestConfig.updateSnapshotsdocs/src/test-api/class-testconfig.md 里对四个值逐条做了解释:all 是执行到的测试全部更新,changed 是只更新没匹配上的、匹配上的不动、同时补齐缺失的,missing 只补缺失,none 一份都不更。

以上为按仓库文档中的参数语义组合的示例,未经实测,以仓库最新内容与 --help 的实际输出为准。

验证分三步做:

  1. 你打算长期使用的那个环境里更新基线(容器方案就在容器里跑);
  2. 不带任何 flag 再跑一次——此时默认是 missing,如果还有测试去创建新文件,说明还有路径没收敛;
  3. git status 看快照目录。docs/src/test-snapshots-js.md 明确要求把 *.spec.ts-snapshots 这类目录提交进版本控制并 review 其中的变更。文件树里如果同时躺着两个平台后缀的同名基线,说明第一步的环境没固定住。

五、什么情况说明不是这个原因

这一步比前面几步更值得花时间,避免把别的问题误诊成平台差异。

  • 报错文件名里的平台段跟当前机器一致,却仍然不匹配。 那就不是路径问题,是内容真的不一样:可能是动态元素、动画、字体加载时序。文档给的对应手段是 stylePathanimations,不是改模板。
  • 首次运行报「A snapshot doesn’t exist…, writing actual」。 文档写明这是还没有 golden file 时的正常行为,它会连拍到两次连续截图一致为止再落盘。新写的测试第一次跑成这样不叫故障。
  • 失败的不是 toHaveScreenshot,而是 toMatchSnapshot 后者比的是文本或任意二进制数据,文档说 Playwright Test 会自动判断内容类型并选择比较算法,它跟浏览器渲染没关系。
  • 快照名传的是路径片段数组。 文档写明 toHaveScreenshot(['relative', 'path', 'to', 'snapshot.png']) 这种写法必须留在该测试文件对应的快照目录内,否则会 throw。这类报错是路径越界,不是平台差异。
  • CI 上快照断言压根没执行。 配置里有 ignoreSnapshots,文档说明它会跳过 toMatchSnapshot()toHaveScreenshot() 这类快照期望;仓库示例里的写法是 ignoreSnapshots: !process.env.CI。如果有人把这个条件写反了,你看到的现象会是「本地不比、CI 才比」。
  • 你用的不是 JS 绑定。 前面说过 toHaveScreenshot 在 API 文档里标的是 langs: js;其它语言绑定的对应能力我们在 docs/src/api/class-pageassertions.md 里没有找到相同的说明,所以不套用本文结论。

最后提醒一句:toHaveScreenshottoMatchSnapshot 的比较行为、配置字段与默认值都在持续调整,本文所有细节以仓库最新内容为准。


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

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