视觉回归测试
Vitest 可以开箱即用地运行视觉回归测试。它会捕获你的 UI 组件和页面的截图,然后将其与参考图像进行比较,以检测非预期的视觉变化。
与验证行为的功能测试不同,视觉测试可以发现样式问题、布局偏移以及渲染问题,而这些问题如果没有充分的人工测试,可能会被忽略。
为什么要进行视觉回归测试?
视觉缺陷不会抛出错误,它们只是看起来不对。这就是视觉测试的作用。
- 那个按钮仍然会提交表单……但为什么它现在变成了亮粉色?
- 这段文字看起来完全没问题……直到有人在移动设备上查看它
- 一切都运行良好……除了那两个容器跑到了视口外面
- 那次小心翼翼的 CSS 重构确实生效了……但把没人测试的某个页面布局弄坏了
视觉回归测试会作为 UI 的安全网,在这些视觉变化到达生产环境之前自动将其捕获。
示例
在 Vitest 中可以通过 toMatchScreenshot 断言 来进行视觉回归测试:
import { expect, test } from 'vitest'
import { page } from 'vitest/browser'
test('button renders in default state', async () => {
// 渲染你的组件
// 捕获并比较截图
await expect(page.getByRole('button')).toMatchScreenshot()
})开始使用
环境稳定性
视觉回归测试对环境差异很敏感,因为渲染在不同环境之间并不是完全确定性的,并且依赖多个因素:
- GPU、驱动程序和硬件加速
- 操作系统
- 字体渲染管线
- 浏览器、浏览器版本和设置
- 浏览器是以 headless 还是 headed 模式运行
- 屏幕缩放、色彩配置文件和显示设置
- ……以及偶尔像是月相 🌗 这种因素
在实践中,即使看似完全相同的环境,也偶尔会产生细微的渲染差异。因此,视觉回归测试在标准化且严格受控的环境中运行时最可靠。这也是为什么强烈建议使用 Docker 容器、仅在 CI 中运行的视觉测试工作流,或云服务。
不是行为测试的替代品
当视觉测试和行为测试同时失败时,更难判断到底哪里坏了,以及原因是什么。视觉失败在有意进行 UI 改动时也属于预期情况,但单元测试失败通常不是。将它们分开意味着每个测试套件都能在合适的原因下明确失败。
值得特别指出的是,toMatchScreenshot 不能替代正确的断言。
一个只渲染按钮并截图的测试,只是在记录当前状态。仅凭截图无法判断用户是否可以与按钮交互。视觉测试最适合作为行为测试之上的补充层,而不是替代品。
换句话说,视觉测试不会告诉你某个东西为什么会以这种方式渲染。它只会告诉你某个东西确实以某种方式渲染了,或者和上一次不一样了。
例如,假设业务需求要求在表格中按购买日期对最近购买进行排序。如果你只看视觉回归测试,可能会注意到上一次测试中的相同条目现在顺序不同了。这可能是因为你刚刚加入了排序,也可能是因为排序坏了。无论哪种情况,仅仅看 UI 都无法知道顺序为什么不同。有人可能会把视觉差异当作噪音而忽略掉,因为表格“看起来没变”,尽管排序逻辑现在已经坏了。现在,生产环境中就出现了一个损坏的业务需求。
项目结构
将视觉测试套件与其他测试分开,可以让失败信号更清晰,也能带来更有意图的更新流程。推荐的设置是使用 projects 并采用 [name].vrt.test.[ext] 的命名约定来保持它们的独立性,同时以 headless 模式运行以确保一致性。由于浏览器实例可能有不同的默认尺寸,还应设置一个特定的 viewport 大小。
import { defaultExclude, defineConfig } from 'vitest/config'
const vrtPattern = '**/*.vrt.test.[tj]s?(x)'
export default defineConfig({
test: {
// ...其他配置
projects: [
{
extends: true,
test: {
name: 'unit',
exclude: [vrtPattern, ...defaultExclude],
},
},
{
extends: true,
test: {
name: 'vrt',
browser: {
headless: true,
instances: [
{
browser: '[browser-name]',
viewport: { width: 1280, height: 720 },
},
],
},
include: [vrtPattern],
},
},
],
},
})配置完成后,添加脚本以分别启动每个项目:
{
"scripts": {
"test:unit": "vitest --project unit",
"test:visual": "vitest --project vrt"
}
}创建参考图
当你第一次运行视觉测试时,Vitest 会创建一张参考(也叫基线)截图,并以如下错误信息使测试失败:
expect(element).toMatchScreenshot()
未找到现有的参考截图;已创建一个新截图。在再次运行测试之前请检查它。
Reference screenshot:
tests/__screenshots__/button.vrt.test.ts/button-default-state-chromium-darwin.png这是正常的。请检查截图是否正确,然后再次运行测试。此时 Vitest 会将后续运行结果与这个基线进行比较。
TIP
参考截图保存在测试文件旁边的 __screenshots__ 文件夹中。请将它们提交到你的仓库。
截图组织方式
默认情况下,截图的组织方式如下:
.
├── __screenshots__
│ └── test-file.vrt.test.ts
│ ├── test-name-chromium-darwin.png
│ ├── test-name-firefox-linux.png
│ └── test-name-webkit-win32.png
└── test-file.vrt.test.ts命名约定包括:
- 测试名称:要么是
toMatchScreenshot()调用的第一个参数,要么是根据测试名称自动生成 - 浏览器名称:取决于所配置的浏览器提供方,例如
chrome、chromium、firefox或webkit - 平台:
aix、darwin、freebsd、linux、openbsd、sunos或win32
这确保了来自不同环境的截图不会相互覆盖。
更新参考图
当你有意更改 UI 时,就需要像更新快照一样更新参考截图:
$ vitest --project vrt --update在提交之前请先检查更新后的截图,确保这些变化是有意的。
过期截图
请注意,已删除或已重命名测试的截图不会自动移除。当你删除或重命名测试时,请手动清理 __screenshots__ 文件夹,否则过期的参考图会随着时间不断累积。
调试失败的测试
当视觉测试失败时,Vitest 会提供三张图片来帮助调试:
- 参考截图:期望的基线图像
- 实际截图:测试期间捕获到的图像
- 差异图:突出显示差异;仅当两张截图尺寸相同时才会生成(使用自定义 matcher 时行为可能不同)
你会在 CLI 输出中看到类似这样的内容:
expect(element).toMatchScreenshot()
截图与存储的参考图不匹配。
245 个像素(比例 0.03)不同。
Reference screenshot:
tests/__screenshots__/button.vrt.test.ts/button-chromium-darwin.png
Actual screenshot:
tests/.vitest/attachments/button.vrt.test.ts/button-chromium-darwin-actual.png
Diff image:
tests/.vitest/attachments/button.vrt.test.ts/button-chromium-darwin-diff.png在 UI 模式下,Vitest 会显示带有 A/B 滑块的分页差异视图,如下所示。
理解差异图
- 红色像素:参考图和实际图之间不同的区域
- 黄色像素:抗锯齿差异(当不忽略 anti-alias 时)
- 透明/原始:未变化的区域
TIP
如果差异图大部分是红色,那说明确实有问题。如果它只是围绕文本散布着少量红色像素,那你可能只需要提高阈值。
配置 toMatchScreenshot 断言
可以通过修改默认选项全局配置 toMatchScreenshot 断言,也可以针对单个测试进行配置。
要更改默认值,你需要修改 Vitest 配置:
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
browser: {
expect: {
toMatchScreenshot: {
comparatorName: 'pixelmatch',
comparatorOptions: {
// 0-1,颜色可以有多大差异?
threshold: 0.2,
// 允许 1% 的像素不同
allowedMismatchedPixelRatio: 0.01,
},
},
},
},
},
})如果需要更细粒度的控制,可以直接向断言传递选项,从而在特定测试中覆盖全局设置:
await expect(element).toMatchScreenshot('button', {
comparatorName: 'pixelmatch',
comparatorOptions: {
// 对文本密集的元素进行更宽松的比较
allowedMismatchedPixelRatio: 0.1,
},
})第三方比较器
Vitest 自带 pixelmatch 作为内置比较器。它速度快、按像素逐一比较图像、没有原生依赖,并且能很好地处理大多数情况。默认不包含感知型比较器,因为它们依赖更重,而且并不存在一个明确的单一“最佳”选择——不同算法会做出不同权衡;不过,比较器 API 的存在正是为了让你接入任何符合需求的实现。虽然随着生态成熟,这个决定未来可能会改变。
对于像素级差异会产生过多噪声的场景,感知型或结构相似度比较器可能更合适。它们更像人类一样比较图像,能容忍细微的渲染差异,同时仍可检测有意义的视觉变化。
这类算法有很多,下面这些是不错的起点:
@blazediff/ssim,用于感知图像质量评估的 SSIM(结构相似性指数) 实现。它提供标准 SSIM、MS-SSIM(多尺度 SSIM)和 Hitchhiker’s SSIM,适用于不同场景@blazediff/gmsd,用于感知图像质量评估的单线程 GMSD(梯度幅值相似性偏差)指标,适合 CI 环境
要使用其中一个,请先安装并注册它:
import ssim from '@blazediff/ssim/ssim'
import type { SsimOptionsExtended } from '@blazediff/ssim/ssim'
import { defineConfig } from 'vitest/config'
declare module 'vitest/browser' {
interface ScreenshotComparatorRegistry {
'standard-ssim': SsimOptionsExtended & {
threshold?: number
}
}
}
export default defineConfig({
test: {
browser: {
expect: {
toMatchScreenshot: {
comparators: {
// 简单实现,请务必查看该库的文档
'standard-ssim': (
reference,
actual,
{ createDiff, ...options }
) => {
const diffBuffer = createDiff
? new Uint8Array(reference.data.length)
: undefined
const output = ssim(
reference.data,
actual.data,
diffBuffer,
reference.metadata.width,
reference.metadata.height,
options,
)
const pass = output >= (options.threshold ?? 0.95)
return {
pass,
diff: diffBuffer ?? null,
message: pass ? null : `SSIM score: ${output}.`,
}
},
},
},
},
},
},
})注册完成后,就可以在配置中或按单个测试来通过名称引用该比较器:
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
browser: {
expect: {
toMatchScreenshot: {
comparatorName: 'standard-ssim',
},
},
},
},
})await expect(button).toMatchScreenshot('button', {
comparatorName: 'standard-ssim',
})最佳实践
测试特定元素
除非你明确想测试整个页面,否则更建议捕获特定组件,以减少误报:
// ❌ 捕获整个页面;容易受到无关更改的影响
await expect(page).toMatchScreenshot()
// ✅ 只捕获正在测试的组件
await expect(
page.getByRole('article', { name: 'Tote bag' })
).toMatchScreenshot()处理动态内容
时间戳、用户数据或随机值等动态内容会导致测试失败。你可以 mock 底层数据源,或者在使用 Playwright provider 时,通过 mask 选项 在 screenshotOptions 中将其遮罩掉。
const profile = page.getByRole(
'article',
{ name: 'Gracie\'s profile' },
)
await expect(profile).toMatchScreenshot({
screenshotOptions: {
mask: [profile.getByRole('status')],
},
})禁用动画
TIP
使用 Playwright provider 时,内置断言会自动禁用动画:screenshotOptions 里的 animations 选项默认会被设置为 "disabled"。
如果你希望禁用所有动画以节省一些执行时间,请继续阅读。
动画会导致测试不稳定。可以在测试期间通过使用 setupFiles 注入自定义 CSS 片段,或者直接在测试中禁用它们:
const stylesheet = document.createElement('style')
stylesheet.textContent = /* css */`
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
}
`
document.head.appendChild(stylesheet)或者,你也可以通过使用 browser.testerHtmlPath 在自定义 HTML 模板中声明这段 CSS。
设置合适的阈值
调整阈值并不容易。它取决于内容、测试环境、你的应用可接受的差异,而且可能还会因测试而变化。
Vitest 没有为不匹配像素定义默认容差。合适的值取决于你的应用和环境。建议使用 allowedMismatchedPixelRatio,这样阈值会根据截图大小计算,而不是固定数值。
同时设置 allowedMismatchedPixelRatio 和 allowedMismatchedPixels 时,Vitest 会采用更严格的那个限制。
使用 Git LFS
如果你计划拥有大型测试套件,请将参考截图存储在 Git LFS 中。
常见问题与解决方案
字体渲染导致的误报
不同系统之间的字体可用性和渲染差异很大。可尝试的解决方案包括:
使用 Web 字体并等待其加载:
ts// 等待字体加载 await document.fonts.ready // 继续你的测试提高文本密集区域的比较阈值:
tsawait expect( page.getByRole('article', { name: 'How to grow tomatoes' }) ).toMatchScreenshot({ comparatorName: 'pixelmatch', comparatorOptions: { // 允许 10% 的像素发生变化 allowedMismatchedPixelRatio: 0.1, }, })考虑使用共享环境配置,以获得一致的字体渲染。
测试不稳定或截图尺寸不同
如果测试会随机通过或失败,或者不同运行之间的截图尺寸不一致:
- 等待所有内容加载完成,包括加载指示器
- 设置明确的视口大小:
await page.viewport(1920, 1080) - 检查视口边界处是否存在响应式行为
- 检查是否有意外的动画或过渡
- 为较大的截图增加测试超时时间
- 考虑使用共享环境配置
团队的视觉测试
即使本地环境受控,在一台机器上生成的参考截图也常常会在另一台机器上失败。一旦不止一个人运行这个测试套件,这个问题就会变得很重要。
在共享环境中运行视觉回归测试套件可以解决这个问题。实现方式有三种:
- 自托管运行器(例如 Docker 镜像),设置和维护都比较复杂
- 在 CI 中生成参考截图,这需要一些配置
- 云服务,例如 Azure App Testing,专门用来解决这个问题,但通常只支持特定的提供商和浏览器
方案 2 和 3 最容易快速上手,因此下面主要介绍它们。
GitHub runners 默认没有预装浏览器。请在运行测试前根据你的 provider 安装它们:
Playwright 可以轻松完成这件事。只需固定版本,并在运行测试前添加这一步:
# ...工作流的其余部分
- name: Install Playwright Browsers
run: npx --no playwright install --with-deps --only-shell然后在你现有的工作流中运行视觉测试:
# ...工作流的其余部分
# ...浏览器设置
- name: Visual Regression Testing
run: npm run test:visual更新工作流
在本地运行 vitest --update 会在你的机器上生成截图,这就违背了受控环境的初衷。相反,你需要一种方式在 CI 中触发更新,因为那里运行测试的环境与生成参考截图的环境一致。
你不希望这件事在每个 PR 上自动发生 (混乱!)。相反,应创建一个手动触发的工作流,在 UI 有意更改时运行。
下面的工作流:
- 仅在功能分支上运行(绝不在 main 上)
- 将触发者列为共同作者
- 防止同一分支上的并发运行
- 显示漂亮的摘要:
当截图发生变化时,它会列出变化内容


当没有任何变化时,它也会告诉你


TIP
这只是其中一种做法。有些人更喜欢 PR 评论(/update-screenshots),也有人使用标签。请根据你的工作流进行调整。
最重要的是拥有一种受控的方式来更新参考截图。
name: Update Visual Regression Screenshots
on:
workflow_dispatch: # 仅手动触发
env:
AUTHOR_NAME: 'github-actions[bot]'
AUTHOR_EMAIL: '41898282+github-actions[bot]@users.noreply.github.com'
COMMIT_MESSAGE: |
test: update visual regression screenshots
Co-authored-by: ${{ github.actor }} <${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com>
jobs:
update-screenshots:
runs-on: ubuntu-24.04
# 安全第一:不要在 main 分支上运行
if: github.ref_name != github.event.repository.default_branch
# 每个分支一次只运行一个
concurrency:
group: visual-regression-screenshots@${{ github.ref_name }}
cancel-in-progress: true
permissions:
contents: write # 需要推送更改
steps:
- name: Checkout selected branch
uses: actions/checkout@v4
with:
ref: ${{ github.ref_name }}
# 如果触发其他工作流请使用 PAT
# token: ${{ secrets.GITHUB_TOKEN }}
- name: Configure Git
run: |
git config --global user.name "${{ env.AUTHOR_NAME }}"
git config --global user.email "${{ env.AUTHOR_EMAIL }}"
# 这里的设置步骤(node, pnpm, 任意)
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx --no playwright install --with-deps --only-shell
- name: Update Visual Regression Screenshots
run: npm run test:visual --update
# 检查发生了什么变化
- name: Check for changes
id: check_changes
run: |
CHANGED_FILES=$(git status --porcelain | awk '{print $2}')
if [ "${CHANGED_FILES:+x}" ]; then
echo "changes=true" >> $GITHUB_OUTPUT
echo "Changes detected"
# 保存列表用于摘要
echo "changed_files<<EOF" >> $GITHUB_OUTPUT
echo "$CHANGED_FILES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
echo "changed_count=$(echo "$CHANGED_FILES" | wc -l)" >> $GITHUB_OUTPUT
else
echo "changes=false" >> $GITHUB_OUTPUT
echo "No changes detected"
fi
# 如果有更改则提交
- name: Commit changes
if: steps.check_changes.outputs.changes == 'true'
run: |
git add -A
git commit -m "${{ env.COMMIT_MESSAGE }}"
- name: Push changes
if: steps.check_changes.outputs.changes == 'true'
run: git push origin ${{ github.ref_name }}
# 给人看的漂亮摘要
- name: Summary
run: |
if [[ "${{ steps.check_changes.outputs.changes }}" == "true" ]]; then
echo "### 📸 Visual Regression Screenshots Updated" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Successfully updated **${{ steps.check_changes.outputs.changed_count }}** screenshot(s) on \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "#### Changed Files:" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "${{ steps.check_changes.outputs.changed_files }}" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "✅ The updated screenshots have been committed and pushed. Your visual regression baseline is now up to date!" >> $GITHUB_STEP_SUMMARY
else
echo "### ℹ️ No Screenshot Updates Required" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The visual regression test command ran successfully but no screenshots needed updating." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "All screenshots are already up to date! 🎉" >> $GITHUB_STEP_SUMMARY
fi选择合适的方案
所有方案都可行。真正的问题是,哪些痛点对你和你的团队最重要。
如果你熟悉容器化,自托管 Docker 方案可以为你提供一个受控环境,不依赖外部服务,也没有额外成本。缺点是维护成本,因为你要自己负责环境、浏览器版本以及任何故障修复。
CI 运行适用于任何浏览器提供商,并且能让你完全掌控,但截图只能在 CI 中生成。如果有人在本地运行 vitest --update 并提交结果,这些参考图在下一次 CI 运行时很可能会失败。可以通过在 CI 环境检查之后再允许执行该命令来避免这个问题。
如果你希望开发者能够在本地运行和更新视觉测试,而不必担心参考图不一致,那么云服务就很合适。当设计师也参与审查变更,或者“推送—等待—检查—修复—再推送”的循环成为真正瓶颈时,它会更有价值。
还在犹豫?先从 CI 工作流开始。如果之后它变成了痛点,你随时可以再迁移到容器或云服务。
进一步了解
Vitest 如何确保截图稳定性
视觉回归测试依赖于跨运行保持截图稳定。实际上,页面不会瞬间稳定:图片会异步加载,动画在不同时间结束,字体会渲染,布局也会逐渐稳定。为缓解这一点,Vitest 使用一种“稳定截图检测”策略:
- 它会先拍摄一张初始截图(如果有参考截图,则使用参考截图)作为基线
- 它再拍摄一张截图,并将其与基线进行比较
- 如果截图匹配,说明页面已稳定,测试继续进行
- 如果它们不同,Vitest 会使用最新的截图作为基线并重复此过程
- 这一过程会持续,直到达到稳定状态或超时
这可以确保临时性的视觉变化(例如加载中的旋转图标或动画)不会导致误报。不过,如果某些内容一直在持续动画,你就会触发超时,因此可以考虑在测试期间禁用动画。
如果在一次或多次重试后捕获到稳定截图,并且存在参考截图,Vitest 会使用 createDiff: true 与参考图进行最终比较。如果它们不匹配,就会生成一张差异图。
在稳定性检测期间,Vitest 会以 createDiff: false 调用比较器,因为它只需要知道截图是否匹配。这样可以让检测过程更快。
