如何使用 Playwright 进行截图测试
使用 Playwright 构建截图测试,包括视觉基线、浏览器项目、稳定的等待条件、差异阈值以及基线更新的审查流程。
10 分钟阅读网站截图作者:Temposhot Team
截图测试会把页面当前的外观与之前批准的图像进行比较。如果像素差异超过允许的范围,测试失败并显示差异。Playwright Test 通过 expect(page).toHaveScreenshot() 内置了这一功能,无需安装额外插件。这种方法也称为视觉回归测试。
本指南中的示例构建了一个测试套件:在 3 个浏览器项目中测试 12 个营销页和应用页面,外加 8 个共享组件。下文中的所有数字,从基线数量到差异阈值,均来自该套件。
何时适合做截图测试
视觉测试能发现 DOM 断言无法发现的问题,例如样式表加载失败、按钮被挤出屏幕、字体回退到系统默认字体。以下情况下,维护视觉测试的成本是值得的:
- 界面有设计系统,且共享组件被多个页面使用
- CSS 经常变更,审查人员无法逐页手动检查
- 布局缺陷曾经出现在生产环境中
对于包含实时数据、广告或用户内容的页面,视觉测试的成本高于收益。每次运行的结果都会不同,测试套件会产生大量误报。
编写测试前先确定套件规模
每个断言在每个项目中都会生成一张基线图像。示例套件包含 12 个整页断言和 8 个组件断言,每个断言在 3 个项目中运行。因此需要创建、审查并在仓库中保存 60 个 PNG 文件。
不应纳入套件的页面
有些页面无论测试如何编写都会每次失败。带有实时图表的仪表盘、用户帖子的动态流以及轮播横幅页面都属于这一类。对于这些页面,应改为测试空状态,或使用固定数据测试单个组件。
示例团队将 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},确保两个平台不会共用同一个文件。
审查人员可以打开一个文件夹,查看某个规范文件的所有基线。重命名测试文件会将其基线移动到新文件夹,因此应在同一个拉取请求中重命名并重新生成基线。
使页面状态保持稳定
只有每次运行都达到相同的页面状态,截图才具有可比性。
- 在断言之前等待一个就绪元素,例如标题或已加载的图像。
- 使用固定的测试数据,而不是实时账户。
- 在配置中设置固定的视口大小。
- 使用
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 次都不会导致测试失败。
在配置中一次性设置默认值
在新测试中,逐次调用的选项很容易被遗漏。配置中的 expect.toHaveScreenshot 块会为每个断言设置默认值。单个测试仍可覆盖这些默认值。
// playwright.config.ts
export default defineConfig({
expect: {
toHaveScreenshot: {
maxDiffPixels: 100,
threshold: 0.2,
animations: 'disabled',
caret: 'hide',
scale: 'css',
},
},
});
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 变更也是代码变更。GitHub 和大多数审查工具会并排显示图像差异。以下检查清单是示例团队在批准前的审查要点。
在 CI 中运行截图测试
在 macOS 上生成的基线无法与 Linux 运行器的捕获结果匹配,因为字体和抗锯齿存在差异。可靠的做法是在同一环境中生成和比较基线,通常使用与已安装包版本一致的官方 Playwright Docker 镜像。
可行的工作流程如下:
- 开发者在 Docker 镜像中运行视觉测试套件,而非直接在 macOS 上运行。
- CI 使用同一镜像运行测试,失败时将 HTML 报告上传为构件。
- 有意的变更生成新基线,并与代码变更一同提交。
- 审查人员在拉取请求中检查图像差异。
在 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'"
视觉测试套件的 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/
在示例中,完整任务大约需要 5 分钟。每个拉取请求只运行 8 个组件断言,36 个页面断言仅在主分支上运行,可将拉取请求的任务时间缩短到 3 分钟以内。
简单的截图自动化适用的场景
视觉测试只回答一个问题:该页面相对于基线是否发生了变化。它们不会生成用于设计会议、客户报告或带日期存档的审阅集,工程团队以外的人员也很少运行它们。
在这些场景中,普通的截图工具更简单。Temposhot Website Capture 在 Mac 上读取 URL 列表,并以桌面 1920×1080、平板 768×1024 或移动端 390×844 的尺寸捕获每个页面,无需编写测试代码。若要人工对比两张截图,可以使用 Compare Images,它会在浏览器中高亮显示差异。截图对比词条介绍了这一方法的通用原理。
两种工具回答两类不同的问题
视觉测试套件与截图工具常在同一团队中并行使用。工程师负责维护测试套件,设计师和产品经理则使用截图进行评审和存档。
常见错误
- 在笔记本电脑上生成基线并提交,然后在 Linux CI 中进行比较。
- 对包含日期、轮播或广告的页面进行整页断言。
- 为了消除不稳定测试的报错而提高阈值,而不是修复页面状态。
- 在一次有意的变更之后,对整个套件运行
--update-snapshots。 - 在桌面基线尚未稳定之前,就添加大量设备项目。
排查失败的视觉构建
视觉任务失败时需要做出判断,而不是直接重新运行。反复重跑直到不稳定的测试通过,会掩盖根本原因,并使下一个真实的回归被放过。以下顺序可以在几分钟内解决大多数失败。
- 下载报告构件,统计失败的断言数量。
- 如果某个项目中的所有断言都失败了,应首先检查该项目的浏览器或字体是否发生了变化。
- 如果某个页面在全部 3 个项目中都失败了,应打开其实际图像,原因通常在页面本身。
- 如果某个页面只在一个项目中失败,应查找特定于浏览器的 CSS 问题,或上表中列出的不稳定来源。
- 修复原因,或仅更新该基线,两者不要在同一次提交中同时进行。
常见问题
基线图像存放在哪里?
默认存放在测试文件旁边的文件夹中,文件夹以测试文件名加 -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。
相关文章
- 如何使用 Playwright 自动化网站截图使用 JavaScript 和 Playwright 自动化网站截图。涵盖单个页面、整页、移动端视图,以及带重试的 URL 批量截图。
- 如何使用 Puppeteer 自动化网站截图使用 JavaScript 和 Puppeteer 自动化网站截图。涵盖单个页面、整页截图、URL 批量处理和移动端视口,并附可复用的脚本。
- 如何在不写代码的情况下自动化网页截图选择无代码的网页截图自动化方式:网址列表批量截图、带截图 API 的自动化平台、监控工具或浏览器辅助工具。
- 2026 年 Mac 上的 Xnapper 替代工具推荐对比 Mac 上的 Xnapper 替代工具:重复截图、滚动截图与录制、免费背景、跨平台指南以及内置应用。