视觉回归怎么做:Playwright toHaveScreenshot 的基线与阈值
改了一行全局 CSS,跑完一整套 e2e 全绿,第二天有人截图问「这个卡片怎么塌了」。这是很常见的一类漏网:常规断言只能断文本、属性、可见性,页面「看起来变了」这件事它一个字都断不到。Playwright 仓库 docs/src/test-snapshots-js.md 开篇给的答案是截图比对——第一次跑生成参考图,之后每次跑都拿实际渲染跟参考图比。
原理不复杂,坑基本都在基线怎么存、阈值怎么定、页面里那些每次都不一样的区域怎么挡住。下面这三件事各自落到哪个文件、哪个参数,一条条说。
一、前置条件:这是 test runner 的能力,不是 library 的
先把范围划清楚,省得配半天发现根本没有这个方法。
docs/src/api/class-pageassertions.md 里 toHaveScreenshot 的两个重载都标了 * langs: js,并且正文明写 “Note that screenshot assertions only work with Playwright test runner.”。docs/src/api/class-snapshotassertions.md 整个类头部同样标 langs: js。也就是说,这套能力绑定在 Playwright Test(JS/TS)上;仓库文档里,视觉比对的指南页文件名是 test-snapshots-js.md,带 -js 后缀,是 JS 专属页——其它语言绑定的对应说明,我们在 docs/src/ 下没有找到。
另外几个选项是分批加进来的,class-pageassertions.md 里每个选项下面都标了 since:toHaveScreenshot 自 v1.23 起可用,maskColor 自 v1.35 起,stylePath 自 v1.41 起。你手里的版本偏旧的话,先去对应文件确认一下再写配置。该项目迭代频繁,以仓库最新内容为准。
还有一条前置条件是环境。test-snapshots-js.md 里有一段 warning 写明:浏览器渲染会因宿主操作系统、版本、设置、硬件、供电方式(电池还是电源适配器)、headless 模式等因素而不同,要想截图一致,就在生成基线的同一环境里跑测试。这句话决定了后面所有的目录结构设计。
二、第一次跑:基线从哪来,落到哪个文件
最小用法是仓库里的这段(原样抄自 docs/src/test-snapshots-js.md):
import { test, expect } from '@playwright/test';
test('example test', async ({ page }) => {
await page.goto('https://playwright.dev');
await expect(page).toHaveScreenshot();
});
第一次跑不会通过,会报:
Error: A snapshot doesn't exist at example.spec.ts-snapshots/example-test-1-chromium-darwin.png, writing actual.
这条报错要看懂三件事。
第一,它已经把图写下来了。文档说明这个方法会连续截图直到相邻两张一致,把最后一张存盘。所以「第一次红」是正常流程,不是配置错了。
第二,目录名以测试文件名打头:example.spec.ts 旁边生成 example.spec.ts-snapshots 目录。文档结尾明确要求把这个目录提交到版本控制,并且对它的每次变更都要 review——基线图跟源码一样是要过 code review 的资产。
第三,文件名后缀是环境指纹。example-test-1-chromium-darwin.png 里,example-test-1 是自动生成的名字,chromium-darwin 是浏览器名与平台;文档还写明,如果配置文件里用了多个 project,这一段会换成 project 名。
这个后缀在源码里能直接查到出处。packages/playwright/src/worker/testInfo.ts 里有一个名为 legacyTemplate 的常量,取值是 '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{-snapshotSuffix}{ext}',代码里它是在没有配置 expect.toHaveScreenshot.pathTemplate、也没有配置 snapshotPathTemplate 时的兜底取值;而 packages/playwright/src/index.ts 里有一行 testInfo.snapshotSuffix = process.platform;。把这两处放在一起就清楚了:末尾那个平台后缀取的是 process.platform 的值。
Windows 这边要特别注意:Windows 上 process.platform 的取值与 macOS、Linux 都不同,所以同一个用例在 Windows 上跑会去找另一个文件名的基线,找不到就按「缺失」处理重新生成。团队里有人用 Windows、有人用 macOS、CI 又跑 Linux 的话,仓库里会并存三套基线;如果你不想要这种局面,要么统一在容器里生成,要么就接受多套并存,别指望一套图跨平台复用。
想自己控制存放位置,用配置里的 snapshotPathTemplate,官方示例是这样的:
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
// Single template for all assertions
snapshotPathTemplate: '{testDir}/__screenshots__/{testFilePath}/{arg}{ext}',
});
可用的 token 在 docs/src/api/params.md 的 test-config-snapshot-path-template 一节列全了,包括 {arg}(不带扩展名的相对路径)、{ext}、{platform}、{projectName}、{testFilePath}、{testName} 等。有个容易忽略的语法:每个 token 前面可以加一个字符,只有当这个 token 取值非空时那个字符才生效——上面默认模板里的 {-projectName} 就是这个用法,没有 project 名时那个连字符不会留下来。另外文档写明,正斜杠 / 在任何平台上都可以当路径分隔符,Windows 上不用换成反斜杠。
三、更新基线:四种模式别记混
页面确实改了,基线就该更新。docs/src/test-snapshots-js.md 给的命令是:
npx playwright test --update-snapshots
关键在于它接受一个可选的 mode 参数。docs/src/test-cli-js.md 的选项表里写明 -u 或 --update-snapshots [mode] 的取值是 all、changed、missing、none 四种,并且给了两条容易记反的默认行为:不带这个 flag 跑测试,默认相当于 missing;带了 flag 但不给值,默认相当于 changed。
四种模式的语义在 docs/src/test-api/class-testconfig.md 的 updateSnapshots 属性下写得更细:all 是所有执行到的测试都更新基线;changed 是只更新没匹配上的,匹配上的不动,同时补齐缺失的;missing 只创建缺失的(这是默认值,注意这是仓库当前文档里的默认值,随版本可能变动);none 一个都不更新。
日常最该用的是 changed,因为它不会把没变的图也重写一遍,diff 干净。而 none 的用处是在 CI 上兜底:显式要求「这次跑绝不许写基线」,避免 CI 悄悄把一张错图当成新基线存了。这两种模式也可以直接写进配置:
import { defineConfig } from '@playwright/test';
export default defineConfig({
updateSnapshots: 'missing',
});
还有个相邻但不同的选项别搞混:updateSourceMethod(自 v1.50 起可用),取值 patch(默认)、3way、overwrite。文档说明它定义的是如何更新源代码里的 snapshot——patch 生成一个统一 diff 文件供你之后手工应用,3way 在源码里生成合并冲突标记让你像解冲突那样挑改动,overwrite 直接覆盖源码。截图基线是独立文件,跟它不是一回事。
四、阈值:两级容差,作用在不同层面
截图比对用的是 pixelmatch 库(docs/src/test-snapshots-js.md 里点名了)。可调的三个选项在 docs/src/api/params.md 里各有定义,语义分两层,分清楚就不会瞎调:
| 选项 | 类型 | 文档里的语义 |
|---|---|---|
threshold | float | 同一位置像素在 YIQ 色彩空间里可接受的感知色差,0 为严格、1 为宽松 |
maxDiffPixels | int | 可接受的不同像素个数,默认未设置 |
maxDiffPixelRatio | float | 不同像素占总像素的比例,取值 0 到 1,默认未设置 |
threshold 管的是「这一个像素算不算变了」,后两个管的是「允许多少个像素变了」。颜色渲染有微小抖动就调前者,某块区域必然有几十个像素飘就调后者。threshold 在文档里写明默认是 0.2——这是仓库当前文档里的默认值,随版本可能变动,别当成长期保证。
单次调用直接传,也可以在配置里给全项目定一个默认(原样抄自仓库文档):
import { defineConfig } from '@playwright/test';
export default defineConfig({
expect: {
toHaveScreenshot: { maxDiffPixels: 100 },
},
});
这里的 100 是仓库文档里的示例值,不是推荐值,你要按自己页面的抖动幅度定。
五、遮罩动态区域:mask 与 stylePath 两条路
页面上总有每次都不一样的东西——时间戳、头像、广告位、嵌进来的 iframe。仓库给了两条挡法。
一是 mask。params.md 里 screenshot-option-mask 定义它接受一个 Array<Locator>,被指定的元素会盖上一个完全覆盖其 bounding box 的粉色方块 #FF00FF,颜色可以用 maskColor 改(自 v1.35 起可用,接受 CSS 颜色格式)。有个细节写在同一段里:遮罩也会作用在不可见元素上,文档指向 locators 页的 “Matching only visible elements” 说明如何关掉这个行为。
import { test, expect } from '@playwright/test';
test('example test', async ({ page }) => {
await page.goto('https://playwright.dev');
await expect(page).toHaveScreenshot({
mask: [page.locator('.timestamp')],
maskColor: '#FF00FF',
});
});
以上为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。
二是 stylePath(自 v1.41 起可用)。给一个 CSS 文件路径,截图时把这份样式打上去。仓库文档给的例子就是挡 iframe:
iframe {
visibility: hidden;
}
params.md 里对它的说明有两点值得记:这份样式表会穿透 Shadow DOM,并且会应用到内层 frame。所以要挡的东西藏在 web component 里、或者在子 frame 里,stylePath 比逐个写 locator 省事。它同样可以在配置里给全项目设默认。
除此之外还有三个默认就在帮你稳定截图的选项:animations 默认是 "disabled",文档说明有限动画会被快进到结束(因此会触发 transitionend),无限动画会被取消回初始状态、截完再播回去;caret 默认 "hide",藏光标;scale 默认 "css",每个 CSS 像素一个图像像素,高 DPI 设备上不会让图翻倍变大。这些都是仓库当前文档里的默认值,随版本可能变动。
六、边界:这几处文档写得很明白
- 只在 test runner 下工作。
class-pageassertions.md与class-snapshotassertions.md都写了这句,library 模式下别找了。 toMatchSnapshot不是用来比图的。class-snapshotassertions.md的两个重载上都挂着 caution 块:要比截图请改用toHaveScreenshot。toMatchSnapshot的定位是比对文本或任意二进制数据,test-snapshots-js.md说它会自动检测内容类型并选比对算法。- 数组形式的路径不能越界。
test-snapshots-js.md写明toHaveScreenshot(['relative', 'path', 'to', 'snapshot.png'])这种写法可用,但这个路径必须留在该测试文件对应的 snapshots 目录内,否则会抛错。 - 配置层能设的选项比调用层少。
docs/src/test-api/class-testconfig.md里expect.toHaveScreenshot这个对象列出的键是animations、caret、maxDiffPixels、maxDiffPixelRatio、scale、stylePath、threshold、pathTemplate;而调用层在class-pageassertions.md里还有mask、maskColor、fullPage、clip、omitBackground。两处放在一起就是:mask这类选项在仓库文档里只出现在调用层的选项表中。仓库里没有找到关于这个差异的说明,这里只把两处摆出来。 - 元素级截图没有
fullPage和clip。class-locatorassertions.md里toHaveScreenshot的选项表没有这两项,page 级的有。 snapshotSuffix已被标为 discouraged:docs/src/test-api/class-testinfo.md里TestInfo.snapshotSuffix那一节明说不鼓励再用它,改用TestConfig.snapshotPathTemplate配置路径。- 可以整体跳过快照断言:
ignoreSnapshots(自 v1.26 起可用)会跳过toMatchSnapshot()与toHaveScreenshot(),官方示例是ignoreSnapshots: !process.env.CI,即本地不跑、CI 才跑。
七、怎么确认自己配对了
第一步,故意在一个干净分支上删掉某张基线跑一次,应该看到那条 A snapshot doesn't exist at ... , writing actual. 的报错,并且对应目录里出现了图。看不到这条报错,说明这个断言压根没执行到,先去查 ignoreSnapshots 和 project 过滤。
第二步,紧接着原样再跑一次,这次应该通过。第一次红、第二次绿,才说明基线写对了位置。
第三步,制造一次真实差异(改个背景色)看失败产物。packages/playwright/src/matchers/toMatchSnapshot.ts 里有两条分支:handleDifferent 处理「比对不过」,按数据是否存在依次挂上以 -expected、-previous、-actual、-diff 结尾的 attachment;handleMissing 处理「基线缺失」,只挂 -expected(且仅在允许写基线时先把实际图写进基线路径)与 -actual,没有 -diff。所以报告里有没有那张 -diff,就是区分这两种失败的最快判据。顺带一提,handleMissing 里那句报错文案也是在代码里拼的:updateSnapshots 不是 none 时才会带上 ”, writing actual.” 这半句。
第四步,验证路径模板。改一下 snapshotPathTemplate 或者给 project 起个名,再跑一次看文件落在哪,比对着 token 列表核对一遍。这一步在多平台团队里尤其值得做,否则你会在合并时才发现仓库里多了一套谁也说不清来路的基线图。
第五步,在 CI 上加 --update-snapshots=none,确认那条流水线不会写基线。
本文依据 github.com/microsoft/playwright 仓库于 2026-08-18 的公开内容整理,
事实来自仓库内的文档与源码。我们没有对文中涉及的功能做过实测,
因此不涉及运行速度、稳定性与实际表现的任何描述。
该项目迭代频繁,文中涉及的 API 签名、配置项与默认值随版本变动,请以仓库最新内容为准。
本文不涉及浏览器版本矩阵与版本清单,相关信息请以官方发布说明为准。