跳至内容

如何使用 Playwright 进行截图测试

使用 Playwright 构建截图测试,包括视觉基线、浏览器项目、稳定的等待条件、差异阈值以及基线更新的审查流程。

10 分钟阅读网站截图作者:Temposhot Team

截图测试会把页面当前的外观与之前批准的图像进行比较。如果像素差异超过允许的范围,测试失败并显示差异。Playwright Test 通过 expect(page).toHaveScreenshot() 内置了这一功能,无需安装额外插件。这种方法也称为视觉回归测试。

本指南中的示例构建了一个测试套件:在 3 个浏览器项目中测试 12 个营销页和应用页面,外加 8 个共享组件。下文中的所有数字,从基线数量到差异阈值,均来自该套件。

何时适合做截图测试

视觉测试能发现 DOM 断言无法发现的问题,例如样式表加载失败、按钮被挤出屏幕、字体回退到系统默认字体。以下情况下,维护视觉测试的成本是值得的:

  • 界面有设计系统,且共享组件被多个页面使用
  • CSS 经常变更,审查人员无法逐页手动检查
  • 布局缺陷曾经出现在生产环境中

对于包含实时数据、广告或用户内容的页面,视觉测试的成本高于收益。每次运行的结果都会不同,测试套件会产生大量误报。

编写测试前先确定套件规模

每个断言在每个项目中都会生成一张基线图像。示例套件包含 12 个整页断言和 8 个组件断言,每个断言在 3 个项目中运行。因此需要创建、审查并在仓库中保存 60 个 PNG 文件。

示例套件的四项统计:12 个页面、3 个浏览器项目、36 个页面基线(12 个页面乘以 3 个项目),以及包含 8 个组件在内的 60 个基线图像。

不应纳入套件的页面

有些页面无论测试如何编写都会每次失败。带有实时图表的仪表盘、用户帖子的动态流以及轮播横幅页面都属于这一类。对于这些页面,应改为测试空状态,或使用固定数据测试单个组件。

示例团队将 5 个候选页面排除在套件之外:2 个仪表盘、1 个每周都有新文章的博客索引页、1 个状态页和 1 个搜索结果页。这些页面通过其共享部分的元素断言进行覆盖。整页列表保持 12 个页面不变。

长篇营销页的整页 PNG 可能达到 1 MB 或更大。60 个这种大小的文件每次更新基线时都会累积到 Git 历史中。组件断言生成的文件较小,变更频率也较低,因此套件的大部分断言应使用组件断言。

安装 Playwright Test

npm init playwright@latest

安装程序会创建 playwright.config.ts、示例测试目录和可选的 CI 工作流,然后下载浏览器。截图断言不需要其他依赖。

固定 Playwright 版本

每个 Playwright 版本都附带特定的浏览器构建。浏览器更新可能会轻微改变文字渲染,导致未改动的截图测试失败。请在 package.json 中固定一个精确版本,并在单独的拉取请求中更新。

{
  "devDependencies": {
    "@playwright/test": "1.48.0"
  }
}

版本更新时,应在同一个拉取请求中重新生成所有基线,并将其作为一批进行审查。如果把版本升级与功能变更混在一起,就无法判断差异是由哪一方引起的。

创建第一个截图测试

// tests/visual.spec.ts
import { test, expect } from '@playwright/test';

test('home page matches baseline', async ({ page }) => {
  await page.goto('https://example.com/');
  await expect(page).toHaveScreenshot('home.png', { fullPage: true });
});

第一次运行时没有可供比较的基线。Playwright 会将捕获的图像写为期望图像,并让测试失败,以便对新基线进行审查。再次运行 npx playwright test,测试会与已保存的文件比对并通过。

之后的运行出现差异时,Playwright 会保存期望图像、实际捕获图像,以及一张高亮显示变化像素的差异图像。运行 npx playwright show-report 生成的 HTML 报告会同时展示这三张图像。

从一个列表测试 12 个页面

编写 12 个独立测试会重复相同的代码。使用页面列表和循环,可以为每个页面生成一个具名测试。测试标题包含页面名称,之后可以使用 -g 只运行其中一个页面。

// tests/pages.visual.spec.ts
import { test, expect } from '@playwright/test';

const pages = [
  { name: 'home', path: '/' },
  { name: 'pricing', path: '/pricing' },
  { name: 'features', path: '/features' },
  { name: 'docs-index', path: '/docs' },
  // ...另外 8 项
];

for (const p of pages) {
  test(`page ${p.name} matches baseline`, async ({ page }) => {
    await page.goto(p.path);
    await page.getByRole('heading', { level: 1 }).waitFor();
    await page.evaluate(() => document.fonts.ready);
    await expect(page).toHaveScreenshot(`${p.name}.png`, { fullPage: true });
  });
}

相对路径需要在配置中设置 baseURL。下一节会将其与本地 Web 服务器一起添加。

针对本地构建运行,而非生产环境

生产环境的页面会因与被测代码无关的原因发生变化,例如内容编辑、A/B 测试和第三方脚本。分支的本地构建只反映该分支所做的变更。webServer 选项会在测试前启动本地服务。

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: { baseURL: 'http://localhost:3000' },
  webServer: {
    command: 'npm run build && npm run start',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
});

reuseExistingServer 允许开发者在本地运行时保持自己的服务器处于运行状态。在 CI 中该值为 false,因此每次任务都会启动新的构建。

测试页面的局部区域

test('pricing card', async ({ page }) => {
  await page.goto('https://example.com/pricing');
  await expect(page.getByTestId('pro-plan')).toHaveScreenshot('pro-card.png');
});

元素级断言的范围更小,因无关原因失败的概率也更低。页眉的变化不会导致定价卡片测试失败。对少数关键页面使用整页断言,对组件使用元素断言。

为每个页面选择断言类型

在示例套件中,12 个页面分为 4 种类型。只有需要作为整体进行评判的页面使用整页断言。由共享组件构成的页面则对这些组件使用元素断言。

按页面类型比较整页断言与元素断言:落地页和定价页使用整页检查,文档页和应用界面则对共享组件进行元素检查。

添加浏览器和设备项目

playwright.config.ts 中的项目可以在多个浏览器和设备配置上运行相同的测试。

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

export default defineConfig({
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
    { name: 'mobile', use: { ...devices['iPhone 13'] } },
  ],
});

每个项目都有各自的基线。文件名包含项目和平台信息,例如 home-chromium-darwin.png。因此 3 个项目意味着每个断言有 3 个基线,应只在布局确实存在差异时添加项目。

将基线保存在可预测的文件夹中

默认位置是每个测试文件旁边的 -snapshots 文件夹。当测试文件较多时,基线会分散在整个测试目录树中。snapshotPathTemplate 选项可将基线统一移动到共享根目录下,每个测试文件对应一个文件夹。

// playwright.config.ts
export default defineConfig({
  snapshotPathTemplate: '{testDir}/__screenshots__/{testFilePath}/{arg}{-projectName}{ext}',
});

该模板省略了平台后缀,前提是所有运行都使用相同的 Linux Docker 镜像。如果仍有部分运行在 macOS 上进行,请在模板中保留 {-snapshotSuffix},确保两个平台不会共用同一个文件。

tests 文件夹的文件树,包含 screenshots 文件夹,每个规范文件对应一个子文件夹,PNG 基线以页面和项目命名,例如 home-chromium.png。

审查人员可以打开一个文件夹,查看某个规范文件的所有基线。重命名测试文件会将其基线移动到新文件夹,因此应在同一个拉取请求中重命名并重新生成基线。

使页面状态保持稳定

只有每次运行都达到相同的页面状态,截图才具有可比性。

  1. 在断言之前等待一个就绪元素,例如标题或已加载的图像。
  2. 使用固定的测试数据,而不是实时账户。
  3. 在配置中设置固定的视口大小。
  4. 使用 await page.evaluate(() => document.fonts.ready) 等待 Web 字体加载完成。

toHaveScreenshot 默认会重试,直到连续两次捕获结果一致,并且会停止 CSS 动画。这两个行为无需额外代码即可消除大部分时序导致的噪声。

等待信号,而非固定时长

固定的 page.waitForTimeout(3000) 会使套件变慢,而且在速度较慢的运行环境中仍会失败。等待特定元素则将断言与页面状态绑定。上方页面循环中的标题等待就是一个例子。

等待网络空闲的可靠性低于表面看起来的程度。轮询 API 或保持长连接的页面永远不会进入空闲状态,等待最终会因超时而结束。应优先等待数据加载完成后才出现的元素,例如表格的第一行。

首屏以下的懒加载图像在滚动到视口之前不会加载。此类页面的整页截图会显示空白方框。可以在断言前滚动到页面底部一次,或在测试构建中关闭懒加载。

固定时间并阻止第三方脚本

"3 分钟前更新"这类日期文本会在每次运行之间发生变化。时钟 API 可以固定页面看到的时间。聊天组件和分析脚本可能在较晚的时刻插入元素,因此测试应在网络层阻止这些请求。

test.beforeEach(async ({ page }) => {
  await page.clock.setFixedTime(new Date('2026-05-01T10:00:00Z'));
  await page.route(/(analytics|chat-widget)\./, (route) => route.abort());
});

阻止脚本也会移除它绘制的内容。这在此处是预期行为:视觉测试检查的是站点自身的布局,而不包括该组件。

不稳定来源及修复方法

示例套件在最初两周内,有 9 次在没有代码变更的情况下失败。每次失败都可追溯到 5 个原因之一。下表列出了每个原因及对应的修复方法。

五种不稳定来源的比较,包括差异图像显示的内容及修复方法:字体、相对日期、懒加载图像、第三方组件以及文本输入框的闪烁光标。

隐藏或遮罩易变元素

await expect(page).toHaveScreenshot('dashboard.png', {
  mask: [page.locator('.timestamp'), page.locator('.user-avatar')],
});

被遮罩的区域在两张图像中都会被纯色方块覆盖,因此其内容会被忽略。对于需要完全隐藏的元素,可通过 stylePath 选项传入样式表,在其中隐藏这些元素。相比直接删除元素,遮罩更可取,因为元素周围的布局仍然会被检查。

按角色而非布局进行遮罩

依赖布局类名的遮罩定位器会在标记结构变化时失效。测试 ID 或无障碍角色在重新设计后依然稳定。在示例套件中,每个易变元素都带有以 volatile- 开头的 data-testid。

await expect(page).toHaveScreenshot('account.png', {
  mask: [page.locator('[data-testid^="volatile-"]')],
  maskColor: '#FF00FF',
});

醒目的 maskColor 能在报告中清楚地标出被遮罩的方块。审查人员可以一眼看出测试忽略了哪些区域。

谨慎设置差异阈值

选项含义
maxDiffPixels允许不同的像素数量。
maxDiffPixelRatio允许不同的像素占比,取值范围为 0 到 1。
threshold单个像素的颜色差异需达到多少才会被计入,取值范围为 0 到 1。默认值为 0.2。

应保持较低的容差。maxDiffPixelRatio 为 0.01 时,允许 1% 的像素发生变化。对于 1280×4000 的整页截图,这相当于超过 50,000 个像素。这足以掩盖一个缺失的图标。建议从约 100 的 maxDiffPixels 开始,只有在特定误报需要时才进行调整。

单张整页截图的阈值计算

以下数字基于一张 1280 × 4000 像素的截图,即示例中定价页的尺寸。一个 24 × 24 像素的图标占 576 个像素。如果使用 1% 的比例容差,该图标消失 88 次都不会导致测试失败。

一张截图的四项统计:总计 5,120,000 个像素,maxDiffPixelRatio 为 0.01 时允许 51,200 个像素变化,一个 24 × 24 像素图标包含 576 个像素,maxDiffPixels 为 100 时允许 100 个像素变化。

在配置中一次性设置默认值

在新测试中,逐次调用的选项很容易被遗漏。配置中的 expect.toHaveScreenshot 块会为每个断言设置默认值。单个测试仍可覆盖这些默认值。

// playwright.config.ts
export default defineConfig({
  expect: {
    toHaveScreenshot: {
      maxDiffPixels: 100,
      threshold: 0.2,
      animations: 'disabled',
      caret: 'hide',
      scale: 'css',
    },
  },
});
设置面板列出截图断言的默认值:maxDiffPixels 为 100,threshold 为 0.2,animations 为禁用,caret 为隐藏,scale 为 css,fullPage 在每个测试中单独设置。

scale: 'css' 使一个 CSS 像素等于一个图像像素,因此类 Retina 的设备配置不会使图像尺寸翻倍。基线文件更小,上文的像素计算也保持有效。

审查并更新基线

当设计变更是有意为之时,应重新生成受影响的基线:

npx playwright test --update-snapshots

应通过文件名或 -g 加测试标题,将更新范围限制在失败的测试上。对整个套件进行全量更新,会使意外变更难以被发现。

npx playwright test tests/pages.visual.spec.ts -g "page pricing" --update-snapshots

按顺序查看三张图像

先查看实际捕获的图像,而不是差异图像。实际图像能显示页面对于人来说是否已经出现异常。随后打开差异图像,找出像素发生变化的位置,再查看期望图像,了解之前的内容。

差异图像用红色标记变化的像素。每个文本块周围出现细红色轮廓,通常表示字体或抗锯齿发生了变化,而非布局缺陷。某处出现实心红色矩形,通常表示元素发生了移动、出现或消失。

HTML 报告还提供期望图像与实际图像之间的滑块视图。拖动滑块经过变化区域,比差异图像更容易看清微小的位移,例如 2 像素的外边距变化。

一个基线的生命周期

每当页面有意变更时,基线都会经历同样的周期。该周期只有一个决策点:判断差异是否符合预期。其余步骤都是机械性的。

包含五个方框的流程:测试失败并生成差异,审查人员打开期望图像、实际图像和差异图像,判断变更是否符合设计意图,仅更新该测试,将新 PNG 与代码变更一起提交。

在拉取请求中审查基线变更

拉取请求中的 PNG 变更也是代码变更。GitHub 和大多数审查工具会并排显示图像差异。以下检查清单是示例团队在批准前的审查要点。

包含六项审查要点的清单:每个变更的 PNG 都与描述的变更相符,没有与代码无关的页面基线发生变化,每个变更的断言都更新了全部 3 个项目,遮罩区域仅覆盖易变元素,没有新的基线显示图像损坏或字体缺失,基线文件数量仅在新增测试时变化。

在 CI 中运行截图测试

在 macOS 上生成的基线无法与 Linux 运行器的捕获结果匹配,因为字体和抗锯齿存在差异。可靠的做法是在同一环境中生成和比较基线,通常使用与已安装包版本一致的官方 Playwright Docker 镜像。

可行的工作流程如下:

  1. 开发者在 Docker 镜像中运行视觉测试套件,而非直接在 macOS 上运行。
  2. CI 使用同一镜像运行测试,失败时将 HTML 报告上传为构件。
  3. 有意的变更生成新基线,并与代码变更一同提交。
  4. 审查人员在拉取请求中检查图像差异。

在 Mac 上使用 Docker 生成基线

镜像标签必须与固定的 Playwright 版本一致。版本不匹配会重新引入字体和渲染差异,而使用镜像正是为了消除这些差异。请在项目根目录运行以下命令。

docker run --rm --ipc=host -v "$(pwd)":/work -w /work \
  mcr.microsoft.com/playwright:v1.48.0-noble \
  /bin/bash -c "npm ci && npx playwright test --update-snapshots -g 'page pricing'"
五个编号步骤:检查固定的 Playwright 版本,拉取版本标签一致的 Docker 镜像,使用 -g 对单个测试运行更新,打开新 PNG 并与旧图像对比,将 PNG 与代码变更一同提交。

视觉测试套件的 CI 任务

该任务在同一镜像中运行,因此无需浏览器下载步骤。只有测试失败时才会上传报告。报告包含每个失败项的期望、实际和差异图像。

# .github/workflows/visual.yml
name: visual
on: pull_request
jobs:
  visual:
    runs-on: ubuntu-latest
    container:
      image: mcr.microsoft.com/playwright:v1.48.0-noble
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright test tests/*.visual.spec.ts
        env:
          HOME: /root
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/
CI 运行的时间线:0:00 检出代码,0:15 安装依赖,1:00 构建站点并启动 Web 服务器,2:30 开始运行 60 个截图断言,5:10 失败时上传报告构件。

在示例中,完整任务大约需要 5 分钟。每个拉取请求只运行 8 个组件断言,36 个页面断言仅在主分支上运行,可将拉取请求的任务时间缩短到 3 分钟以内。

简单的截图自动化适用的场景

视觉测试只回答一个问题:该页面相对于基线是否发生了变化。它们不会生成用于设计会议、客户报告或带日期存档的审阅集,工程团队以外的人员也很少运行它们。

在这些场景中,普通的截图工具更简单。Temposhot Website Capture 在 Mac 上读取 URL 列表,并以桌面 1920×1080、平板 768×1024 或移动端 390×844 的尺寸捕获每个页面,无需编写测试代码。若要人工对比两张截图,可以使用 Compare Images,它会在浏览器中高亮显示差异。截图对比词条介绍了这一方法的通用原理。

两种工具回答两类不同的问题

视觉测试套件与截图工具常在同一团队中并行使用。工程师负责维护测试套件,设计师和产品经理则使用截图进行评审和存档。

Playwright 视觉测试与 Temposhot Website Capture 的比较,涵盖回答的问题、使用人员、设置方式、登录后页面、输出结果以及运行环境。

常见错误

  • 在笔记本电脑上生成基线并提交,然后在 Linux CI 中进行比较。
  • 对包含日期、轮播或广告的页面进行整页断言。
  • 为了消除不稳定测试的报错而提高阈值,而不是修复页面状态。
  • 在一次有意的变更之后,对整个套件运行 --update-snapshots。
  • 在桌面基线尚未稳定之前,就添加大量设备项目。

排查失败的视觉构建

视觉任务失败时需要做出判断,而不是直接重新运行。反复重跑直到不稳定的测试通过,会掩盖根本原因,并使下一个真实的回归被放过。以下顺序可以在几分钟内解决大多数失败。

  1. 下载报告构件,统计失败的断言数量。
  2. 如果某个项目中的所有断言都失败了,应首先检查该项目的浏览器或字体是否发生了变化。
  3. 如果某个页面在全部 3 个项目中都失败了,应打开其实际图像,原因通常在页面本身。
  4. 如果某个页面只在一个项目中失败,应查找特定于浏览器的 CSS 问题,或上表中列出的不稳定来源。
  5. 修复原因,或仅更新该基线,两者不要在同一次提交中同时进行。
视觉测试套件的做法与禁止事项列表:固定版本,在 Docker 中生成基线,每次只更新一个测试;不要提高阈值,不要对实时页面进行断言,不要在功能拉取请求中升级 Playwright 版本。

常见问题

基线图像存放在哪里?

默认存放在测试文件旁边的文件夹中,文件夹以测试文件名加 -snapshots 后缀命名。snapshotPathTemplate 配置选项可以更改存放位置。

不使用 Playwright Test 能否对比截图?

可以。使用任意工具截图,再用图像差异工具比较文件即可。Playwright Test 在此基础上增加了重试、遮罩和报告功能。

截图测试是否应在每个拉取请求中运行?

对于组件级断言,通常应该运行。如果大型整页套件拖慢了流水线,可以改为定时运行,或在发布前运行。

为什么测试在本地通过,在 CI 中却在没有代码变更的情况下失败?

两种环境渲染文字的方式不同。macOS 与 Linux 之间的字体、抗锯齿和浏览器构建都可能存在差异。应在本地和 CI 中使用同一个 Docker 镜像运行套件。

新套件应从几个项目开始?

从一个开始。先使用单个桌面项目,直到其基线保持稳定一到两周。然后添加移动端项目,因为移动端布局的差异最大。最后再添加第二个桌面浏览器,并且只有在用户反馈存在特定于浏览器的布局缺陷时才添加。

套件能否测试需要登录的页面?

可以。在一个 setup 项目中登录一次,使用 storageState 保存会话,然后在视觉项目中加载该文件。应使用专用的测试账户和固定数据,使页面上的姓名和数字在每次运行时保持一致。

基线是否应使用 Git LFS 存储?

当套件包含数百个图像,或 PNG 文件变更频繁时,Git LFS 会有帮助。对于约 60 个文件的套件,普通 Git 通常已经足够。在更新基线几个月后,应检查仓库大小。

参考资料

需要无代码的网站截图自动化?

当目标是获得一组截图,而非通过或失败的结果时,Temposhot Website Capture 可以在 Mac 本地运行 URL 列表。请参阅网站截图自动化或 Website Capture 快速入门。

playwrightvisual-testingscreenshot-comparisonci

本文使用的术语: playwright, responsive screenshot, screenshot automation, screenshot comparison, viewport screenshot, visual regression testing

把重复性截图整理为成品

Temposhot 可按定时或每次点击进行截图,并将结果导出为 PDF、图片、ZIP 或 MP4。

相关文章