我们先声明一下利益相关:我们是 Geonode,主要销售代理服务,而通过代理截图是我们的产品中较为“干净”的应用场景之一——验证网页在其他国家/地区实际显示的效果,这是任何 API 都无法告诉你的。 **需要坦诚提醒的是成本问题:浏览器会预加载所有图片、字体、脚本和视频,因此在计费流量下,截图操作是消耗带宽最严重的行为。**下文将介绍如何降低这一成本,其中介绍的技术能为您节省的费用,甚至比选择更便宜的供应商还要多。
三种截图类型
视口 — 当前可见的内容,默认选项:
await page.screenshot({ path: 'viewport.png' });
整页 — 文档 将其描述为“可滚动页面的完整截图,仿佛你拥有一块非常高的屏幕,且页面能完全填满它”:
await page.screenshot({ path: 'full.png', fullPage: true });
元素 — “有时截取单个元素的屏幕截图会很有用”:
await page.getByRole('article').screenshot({ path: 'element.png' });
选择哪种截图方式主要取决于你打算如何使用结果。视口截图回答的是“用户首先看到什么”。 全页面截图回答“该页面上有什么内容”。元素截图回答“这个组件看起来是否正确”,而且在三种截图中,它们是最适合用于比较的,因为它们排除了你未询问的所有内容。
全屏截图及其常见问题
fullPage: true 是人们常用的选项,但也是限制最多的。
延迟加载的内容可能无法被捕获。 Playwright 会滚动页面进行截图,但那些在元素进入视口时才加载的图片和组件,在截图完成时可能尚未加载完毕。 可靠的解决方法是刻意滚动并等待预期内容加载完成:
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await expect(page.getByRole('img').last()).toBeVisible();
await page.evaluate(() => window.scrollTo(0, 0));
await page.screenshot({ path: 'full.png', fullPage: true });
粘性页眉会重复出现或位置异常。 具有 position: fixed 或 sticky 的元素在拼接截图中行为难以预测。style 选项——即“在截图过程中注入页面以进行样式设置的 CSS 字符串”——是干净利落的解决方法:
await page.screenshot({
path: 'full.png',
fullPage: true,
style: '.sticky-header { position: absolute !important; }',
});
非常长的页面会生成非常大的文件。 无限滚动信息流没有自然的底部。建议使用 clip 来捕获定义好的区域,该方法需要“一个指定裁剪生成的图像的对象”。
覆盖层也会被捕获。 Cookie 提示横幅、聊天小部件和模态窗口在截图中会完全按照用户所见的方式显示。若不想包含这些内容,请先将其关闭——如果无法关闭,mask 便是解决此问题的工具。
元素截图与缓冲区
元素截图会将元素滚动到可见区域,并仅捕获其边界框,因此是组件级检查的理想默认选项。
const card = page.getByTestId('product-card').first();
await card.screenshot({ path: 'card.png' });
它们有两点无法实现:无法捕获被 overflow: hidden
裁剪的内容,也无法捕获元素边界框外的任何内容,即使这些内容在视觉上与元素重叠。
使用缓冲区而非文件。 文档中指出:“与其写入文件,不如获取包含图像的缓冲区,然后对其进行后处理,或将其传递给第三方像素差异分析工具”。 省略 ``path`
` 即可获取原始字节流:
const buffer = await page.screenshot();
const base64 = buffer.toString('base64');
只要截图需要传输到本地磁盘以外的地方(例如对象存储、API、报告或差异对比服务),这就是你需要的形式。它还能完全绕过文件系统,这对磁盘资源短暂的容器化运行器尤为重要。
确保截图可复现的选项
如果您需要对比多张截图,这些选项必不可少。如果您只是粗略查看其中一张,则可以忽略它们。
| 选项 | 值 | 功能 |
|---|---|---|
animations |
| disabled
, allow
| “设置为‘disabled’时,会在截图过程中暂停 CSS 动画” |
| caret
| hide
, initial
| “设置为‘hide’时,会在截图过程中隐藏文本光标” |
| mask
| Locator[]
| “指定截图时应被遮罩的定位器” |
| maskColor
| CSS 颜色,默认值 #F0F
| “指定遮罩区域的颜色” |
| scale
| css
, device
| “网页渲染的缩放比例” |
| omitBackground
| 布尔值,默认值 false
| “隐藏默认白色背景,并允许捕获透明截图” |
| type
| png
, jpeg
, 默认值 png
| “指定截图文件格式” |
| quality
| 0–100 | “JPEG 格式的图像质量” — 仅限 JPEG |
| style
| CSS 字符串 | 在捕获期间注入到页面中 |
能够解决大多数可重复性问题的四项设置:
**animations: 'disabled'
** 可消除同一页面两次捕获结果之间最大的差异来源。任何正在进行的 CSS 过渡效果都会导致每次运行时像素结果不同。
**mask
** 将区域替换为纯色,这样既能排除真正会变化的内容(如时间戳、会话标识符、个性化推荐、广告),又不会完全放弃比较:
await page.screenshot({
path: 'page.png',
mask: [page.getByTestId('timestamp'), page.locator('.ad-slot')],
maskColor: '#000000',
});
**scale: 'css'
** 以 CSS 像素尺寸而非设备像素比例进行截图,因此高 DPI 设备和 CI 运行器生成的图像大小可比。 请显式设置该选项,而非依赖默认值,因为这是笔记本电脑与构建服务器之间最可能存在差异的选项。
**caret: 'hide'
** 可移除闪烁的文本光标,否则在任何带有获得焦点的输入框的页面截图中,大约有一半会出现该光标。
为了确保可重现性,还有三件事需要确定,其中均不属于截图选项:在配置中固定视口大小、固定区域设置和时区,以及固定字体——开发者机器与容器中的字体可用性存在差异,而不同的字体会导致不同的布局。
测试失败时的自动截图
这是 Playwright 中最具价值的截图配置,仅需一行代码:
export default defineConfig({
use: {
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
video: 'retain-on-failure',
},
});
screenshot: 'only-on-failure'
会在测试失败的瞬间捕获页面并将其附加到报告中。可选配置包括 off
、on
和 only-on-failure
;on
会为每次测试捕获截图,从而产生大量输出文件。
值得特别强调的是将其与 trace
结合使用,因为跟踪记录包含 DOM 快照、网络活动以及每项操作的时序信息。截图只能告诉你页面显示异常;而跟踪记录则能揭示原因。对于任何无人值守运行的测试,这两项都应开启。
此配置也是诊断一类令人困惑的故障最快捷的方式:页面虽然渲染成功,但显示的却不是你预期的内容。挑战页面、登录重定向或区域变体都会导致超时,这些情况在查看截图前,看起来都像是元素问题。
与 toHaveScreenshot
的视觉对比 用于实际的视觉回归测试(而非临时截图):
await expect(page).toHaveScreenshot('homepage.png');
await expect(page.getByRole('navigation')).toHaveScreenshot('nav.png');
首次运行时会生成基线;后续运行时会进行对比,若存在差异则报错。可通过 --update-snapshots
有针对性地更新基线。
三点实用注意事项。
基线具有平台特异性。 不同操作系统之间的字体渲染方式存在差异,因此在 macOS 上生成的基线不会与在 Linux 容器中生成的基线匹配。请在测试运行的同一环境中生成基线——通常是在持续集成(CI)环境中,通常通过一个您也可以在本地运行的容器。
设置容差阈值。 精确的像素匹配会导致因抗锯齿差异而失败,而这些差异连人眼都无法察觉。在配置中设置 maxDiffPixels
或 maxDiffPixelRatio
,才能让测试套件真正可用。
在开始前屏蔽所有变量。 凡是因时间戳变化就失败的视觉测试,一周内就会被禁用,这还不如没有它。
通过代理截屏
这确实是我们擅长的领域,而且是个很好的示例。
在 Playwright 配置中设置代理:
const context = await browser.newContext({
proxy: { server: 'http://proxy.example.com:9000', username: 'u', password: 'p' },
locale: 'de-DE',
timezoneId: 'Europe/Berlin',
});
请注意代理旁边的 locale
和 timezoneId
。一个位于德国、区域设置为 en-US
且时区为伦敦的出口地址,这种组合在现实中并不存在,而许多网站会根据区域设置(而非仅凭地址)来决定展示什么内容。请将这三者同时设置好,否则你测试的结果将与预期不符。
**这真正能解答的问题是:**该国真实访客所看到的内容。包括区域定价、货币显示、商品库存、促销横幅、您的广告是否投放到了付费指定的位置,以及内容旁边显示的内容。没有任何 API 能提供这些信息,因为答案是一页已渲染的网页。
成本及其削减方法。 浏览器会抓取所有内容。对于按流量计费的家庭网络——以我们为例,起价为 0.79 美元/GB(2026 年 9 月根据我们的 定价页面 核查)——在二十个市场运行一次截图测试,费用会迅速累积。 屏蔽不需要的资源类型是最大的优化手段:
await page.route('**/*.{woff,woff2,mp4,webm}', route => route.abort());
请注意该列表中未包含的内容。如果截图是最终交付物,则不能屏蔽图片——那样就违背了初衷。 屏蔽字体和多媒体内容,保留图片,并接受这样一个事实:视觉验证本质上是一种成本高昂的代理工作。当您只需确认文本内容而非外观时,也应屏蔽图片,并完全跳过截图步骤。
同时验证地理位置是否确实匹配。 截取屏幕截图并仔细查看。 如果通过巴西出口捕获的页面显示的价格与你的办公桌前看到的一样,那么无论 IP 查询报告结果如何,定向功能都未起作用。这正是我们在 为什么测试代理很重要 中描述的“沉默失败”——而截图特别擅长发现这种问题,因为人只需一眼就能看出来。
大规模截图
一旦需要截取的截图数量超过寥寥几张,采取以下几种做法可以避免工作变得难以管理。
复用浏览器,而非上下文。 启动浏览器开销很大;而创建上下文的开销很小。 对于涉及多个页面或多个区域的测试,只需启动一次浏览器,并针对每个工作单元创建一个新的上下文——这样既能获得隔离的 Cookie 和存储空间,又无需反复承担启动成本:
const browser = await chromium.launch();
for (const country of countries) {
const ctx = await browser.newContext({ proxy: { server: proxyFor(country) } });
const page = await ctx.newPage();
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.screenshot({ path: `shots/${country}.png`, fullPage: true });
await ctx.close();
}
await browser.close();
不要将 networkidle 用作等待条件。 包含分析信标、WebSocket 或轮询功能的页面永远不会处于空闲状态,导致等待超时。 请等待能告知页面已就绪的元素:
await page.goto(url);
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
await page.screenshot({ path: 'shot.png' });
有意识地限制并发数。 每个浏览器上下文都会消耗实际内存——页面加载完成后,消耗几百兆字节是正常的。在小型运行器上并行运行三十个任务会导致失败,这些失败看似超时,实则因机器内存不足所致。 建议从 4 或 5 个开始,并在监控内存使用情况的同时逐步增加。
为文件命名时要便于查找。 类似 screenshot-1.png 到 screenshot-400.png 的目录结构无法使用。请在文件名中包含目标、区域和时间戳,并将 URL 与图片一同保存。
归档前请先压缩。 PNG 格式虽为无损压缩,但文件体积庞大。如果图片仅用于人工审查而非像素级对比,则采用 quality: 80 设置的 JPEG 格式通常仅占原文件大小的几分之一,且视觉上无法分辨差异——对于按计划在二十个市场同时运行的任务而言,这种差异直接关系到存储费用。
在不中断任务的情况下处理故障。 单个页面无法加载不应导致其余十九个页面全部放弃。将每次抓取封装起来,记录错误并继续执行——随后报告哪些目标失败,而不是等到任务在第三个目标处完全失败时才发现问题。
何时不应使用截图
当你需要数据时。 如果你需要价格,就直接提取价格。一个数字的截图,只是一个你必须从图片中读出的数字。截图用于展示外观;选择器则用于获取内容。
当你想知道测试失败的原因时。 跟踪日志的信息量显然更丰富,而且其中本就包含截图。
当页面非常庞大时。 无限滚动页面的全页截图会生成巨大的文件,没人会去打开。请裁剪到关键区域即可。
当你需要检查文本时。 请对文本进行断言。expect(locator).toHaveText() 会生成可读的失败消息;像素差异比对只会给你一张图片。
当需要大规模归档时。 截图文件很大,如果涉及多个市场且数量达数千张,不仅会占用大量存储空间,还会消耗大量带宽。建议存储哈希值或差异比对结果,仅在内容发生变化时保留完整图片。
大家还问
如何在 Playwright 中截屏?使用
await page.screenshot({ path: 'shot.png' }) 截取视口,{ fullPage: true } 截取整个可滚动页面,locator.screenshot() 截取单个元素。省略 path 参数可将截图保存在缓冲区中,而非写入文件。
如何截取全页面截图?
传入 fullPage: true。请注意,延迟加载的内容可能尚未加载完毕,且粘性或固定元素在拼接后的结果中可能会出现异常行为——请先有意识地滚动页面,并使用 style 选项在截图过程中消除粘性定位。
如何截取单个元素的屏幕截图?
在定位器(而非页面)上调用 screenshot():await page.getByTestId('card').screenshot({ path: 'card.png' })。Playwright 会将该元素滚动到可见区域,并捕获其边界框。被 overflow: hidden 裁剪的内容不会被包含在内。
如何确保 Playwright 截图在不同运行之间保持一致?
设置 animations: 'disabled' 和 caret: 'hide',使用 mask 遮罩可变区域,并显式设置 scale。然后固定视口大小、区域设置、时区和可用字体,因为这四项都会影响布局,且均不属于截图选项。
如何在测试失败时自动截取屏幕截图?
在 Playwright 配置的 use 块中设置 screenshot: 'only-on-failure'。将其与 trace: 'retain-on-failure' 结合使用——跟踪记录包含 DOM 快照、网络活动和操作计时,这不仅能显示失败结果,还能解释失败原因。
能否将屏幕截图以 base64 格式获取,而不是作为文件?
可以。省略 path 选项后,screenshot() 会返回一个缓冲区,你可以使用 buffer.toString('base64') 进行转换。文档建议将此方法用于后处理或传递给像素差异比较服务,并且在临时 CI 运行器中可以避免使用文件系统。
如何在截图中隐藏动态内容?
使用mask选项并配合一个定位器数组,该选项会将指定区域替换为纯色——maskColor默认值为#F0F,可自行修改。通过这种方式,即使页面包含时间戳、会话数据或广告,视觉对比依然有效。
我可以通过代理截屏来查看地区性页面吗?
可以,这也是使用代理的较佳用途之一。在浏览器环境中设置代理,并将 locale 和 timezoneId 设置为与目标国家/地区相匹配——许多网站会独立于 IP 地址使用区域设置。然后查看生成的截图,以确认区域内容确实存在差异,而不是仅依赖 IP 查询结果。
总结
在 Playwright 中截取屏幕截图只需一行代码。但要截取一张有实际意义的截图,则需要多花一点功夫。
如果截图只是供人快速查看——比如失败凭证、错误报告,或是检查页面在巴西的显示效果——那么默认设置就足够了,而在配置中添加 screenshot: 'only-on-failure' 就是你能添加的最具价值的代码行。建议配合跟踪信息使用,因为跟踪信息能解释截图仅能展示的内容。
如果截图需要与另一张截图进行对比,情况就完全不同了。 禁用动画、隐藏光标、遮盖可变区域、固定缩放比例,并锁定视口、区域设置、时区和字体。随后在测试运行的同一环境中生成基线,因为不同平台上的字体渲染效果各异,你笔记本电脑生成的基线永远无法与容器环境完全匹配。
至于地理位置验证——这也是渲染后的页面真正胜过结构化数据的地方——请同时设置代理、区域设置和时区,然后查看图片以确认定位是否有效。带宽是成本,图片是唯一无法被阻塞的资源类型,而这正是视觉验证所必需的代价。
