视觉回归测试
Vitest 可以开箱即用地运行视觉回归测试。它会捕获 UI 组件和页面的截图,然后将它们与参考图像进行比较,以检测意外的视觉变化。
与验证行为的功能测试不同,视觉测试能够捕捉样式问题、布局偏移和渲染问题,这些问题如果没有彻底的手动测试可能会被忽视。
为什么要进行视觉回归测试?
视觉错误不会抛出错误,它们只是看起来不对。这就是视觉测试发挥作用的地方。
- 那个按钮仍然可以提交表单……但为什么现在变成了亮粉色?
- 文本完美适配……直到有人在手机上查看
- 一切运行良好……除了那两个容器超出了视口
- 那个仔细的 CSS 重构起作用了……但破坏了一个没人测试的页面的布局
视觉回归测试充当了 UI 的安全网,在这些视觉变化到达生产环境之前自动捕获它们。
入门
浏览器渲染差异
视觉回归测试在不同环境中本质上是不稳定的。截图在不同机器上看起来会有所不同,原因是:
- 字体渲染(这是个大问题。Windows、macOS、Linux,它们渲染文本的方式都不同)
- GPU 驱动程序和硬件加速
- 是否以无头模式运行
- 浏览器设置和版本
- ……老实说,有时甚至取决于月相
这就是为什么 Vitest 在截图名称中包含浏览器和平台(例如 button-chromium-darwin.png)。
为了获得稳定的测试,请在所有地方使用相同的环境。我们强烈建议使用云服务,如 Azure App Testing 或 Docker 容器。
Vitest 中的视觉回归测试可以通过 toMatchScreenshot 断言 来完成:
import { expect, test } from 'vitest'
import { page } from 'vitest/browser'
test('hero section looks correct', async () => {
// ... 测试的其余部分
// 捕获并比较截图
await expect(page.getByTestId('hero')).toMatchScreenshot('hero-section')
})创建参考图
当你第一次运行视觉测试时,Vitest 会创建一个参考(也称为基线)截图,并使用以下错误消息使测试失败:
expect(element).toMatchScreenshot()
未找到现有的参考截图;已创建一个新截图。在再次运行测试之前请检查它。
参考截图:
tests/__screenshots__/hero.test.ts/hero-section-chromium-darwin.png这是正常的。检查截图看起来是否正确,然后再次运行测试。Vitest 现在会将未来的运行与此基线进行比较。
TIP
参考截图位于测试旁边的 __screenshots__ 文件夹中。 别忘了提交它们!
截图组织
默认情况下,截图的组织方式如下:
.
├── __screenshots__
│ └── test-file.test.ts
│ ├── test-name-chromium-darwin.png
│ ├── test-name-firefox-linux.png
│ └── test-name-webkit-win32.png
└── test-file.test.ts命名约定包括:
- 测试名称:
toMatchScreenshot()调用的第一个参数,或从测试名称自动生成。 - 浏览器名称:
chrome、chromium、firefox或webkit。 - 平台:
aix、darwin、freebsd、linux、openbsd、sunos或win32。
这确保了来自不同环境的截图不会相互覆盖。
更新参考图
当你有意更改 UI 时,你需要更新参考截图:
$ vitest --update在提交之前检查更新的截图,以确保更改是有意的。
视觉测试的工作原理
视觉回归测试需要稳定的截图来进行比较。但是页面不会瞬间稳定,因为图像正在加载、动画结束、字体渲染以及布局稳定。
Vitest 通过“稳定截图检测”自动处理这个问题:
- Vitest 拍摄第一张截图(如果可用则使用参考截图)作为基线
- 它拍摄另一张截图并将其与基线进行比较
- 如果截图匹配,页面稳定且测试继续
- 如果它们不同,Vitest 使用最新的截图作为基线并重复
- 这将持续直到达到稳定或达到超时
这确保了短暂的视觉变化(如加载旋转器或动画)不会导致错误失败。但是如果某些东西从未停止动画,你将达到超时,所以考虑 在测试期间禁用动画。
如果在重试(一次或多次)后捕获了稳定截图且存在参考截图,Vitest 会使用 createDiff: true 执行与参考图的最终比较。如果它们不匹配,这将生成差异图像。
在稳定性检测期间,Vitest 使用 createDiff: false 调用比较器,因为它只需要知道截图是否匹配。这使检测过程保持快速。
配置视觉测试
全局配置
在你的 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-hover', {
comparatorName: 'pixelmatch',
comparatorOptions: {
// 对文本密集的元素进行更宽松的比较
allowedMismatchedPixelRatio: 0.1,
},
})最佳实践
测试特定元素
除非你明确想要测试整个页面,否则建议捕获特定组件以减少误报:
// ❌ 捕获整个页面;容易受到无关更改的影响
await expect(page).toMatchScreenshot()
// ✅ 仅捕获正在测试的组件
await expect(page.getByTestId('product-card')).toMatchScreenshot()处理动态内容
时间戳、用户数据或随机值等动态内容会导致测试失败。你可以模拟动态内容的来源,或者在使用 Playwright 提供者时通过使用 screenshotOptions 中的 mask 选项 来屏蔽它们。
await expect(page.getByTestId('profile')).toMatchScreenshot({
screenshotOptions: {
mask: [page.getByTestId('last-seen')],
},
})禁用动画
动画可能导致测试不稳定。通过注入自定义 CSS 代码片段在测试期间禁用它们:
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
}TIP
使用 Playwright 提供者时,使用断言会自动禁用动画:screenshotOptions 中 animations 选项的值默认设置为 "disabled"。
设置适当的阈值
调整阈值很棘手。它取决于内容、测试环境、你的应用可接受的内容,并且也可能根据测试而变化。
Vitest 不为不匹配的像素设置默认值,这由用户根据其需求决定。建议使用 allowedMismatchedPixelRatio,以便阈值是根据截图的大小计算的,而不是固定数字。
当同时设置 allowedMismatchedPixelRatio 和 allowedMismatchedPixels 时,Vitest 使用更严格的限制。
设置一致的视口大小
由于浏览器实例可能具有不同的默认大小,最好设置特定的视口大小,无论是在测试上还是在实例配置上:
await page.viewport(1280, 720)import { playwright } from '@vitest/browser-playwright'
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
browser: {
enabled: true,
provider: playwright(),
instances: [
{
browser: 'chromium',
viewport: { width: 1280, height: 720 },
},
],
},
},
})使用 Git LFS
如果你计划拥有大型测试套件,请将参考截图存储在 Git LFS 中。
调试失败的测试
当视觉测试失败时,Vitest 提供三张图像来帮助调试:
- 参考截图:预期的基线图像
- 实际截图:测试期间捕获的内容
- 差异图像:突出显示差异,但这可能不会生成
你会看到类似的内容:
expect(element).toMatchScreenshot()
截图与存储的参考图不匹配。
245 个像素(比例 0.03)不同。
参考截图:
tests/__screenshots__/button.test.ts/button-chromium-darwin.png
实际截图:
tests/.vitest-attachments/button.test.ts/button-chromium-darwin-actual.png
差异图像:
tests/.vitest-attachments/button.test.ts/button-chromium-darwin-diff.png理解差异图像
- 红色像素是参考图和实际图之间不同的区域
- 黄色像素是抗锯齿差异(当不忽略抗锯齿时)
- 透明/原始是未更改的区域
TIP
如果差异大部分是红色的,说明真的有问题。如果文本周围点缀着几个红色像素,你可能只需要提高阈值。
常见问题和解决方案
字体渲染导致的误报
字体的可用性和渲染在不同系统之间差异很大。一些可能的解决方案可能是:
使用 Web 字体并等待它们加载:
ts// 等待字体加载 await document.fonts.ready // 继续你的测试增加文本密集区域的比较阈值:
tsawait expect(page.getByTestId('article-summary')).toMatchScreenshot({ comparatorName: 'pixelmatch', comparatorOptions: { // 允许 10% 的像素发生变化 allowedMismatchedPixelRatio: 0.1, }, })使用云服务或容器化环境以获得一致的字体渲染。
测试不稳定或截图尺寸不同
如果测试随机通过和失败,或者截图在不同运行之间具有不同的尺寸:
- 等待所有内容加载,包括加载指示器
- 设置明确的视口大小:
await page.viewport(1920, 1080) - 检查视口边界的响应行为
- 检查意外的动画或过渡
- 增加大截图的测试超时时间
- 使用云服务或容器化环境
团队的视觉回归测试
还记得我们提到过视觉测试需要稳定的环境吗?好吧,事实是:你的本地机器并不是那个环境。
对于团队来说,你基本上有三个选项:
- 自托管运行器,设置复杂,维护痛苦
- GitHub Actions,免费(对于开源项目),适用于任何提供商
- 云服务,比如 Azure App Testing, 专为解决此问题而建
我们将重点关注选项 2 和 3,因为它们启动最快。
坦白说,每种方案的主要权衡如下:
- GitHub Actions:视觉测试仅在 CI 中运行(开发人员无法在本地运行)
- Microsoft 的服务:随处可用,但需要付费且仅适用于 Playwright
这里的技巧是将视觉测试与常规测试分开, 否则,你将浪费数小时检查截图不匹配的失败日志。
组织你的测试
首先,隔离你的视觉测试。将它们放在 visual 文件夹中(或者任何 适合你项目的结构):
{
"scripts": {
"test:unit": "vitest --exclude tests/visual/*.test.ts",
"test:visual": "vitest tests/visual/*.test.ts"
}
}现在开发人员可以在本地运行 npm run test:unit 而不会受到视觉测试的 干扰。视觉测试保留在环境一致的 CI 中。
CI 设置
你的 CI 需要安装浏览器。具体操作取决于你的提供商:
Playwright 让这变得很简单。只需固定 你的版本并在运行测试前添加以下内容:
# ...工作流的其余部分
- name: Install Playwright Browsers
run: npx --no playwright install --with-deps --only-shell然后运行你的视觉测试:
# ...工作流的其余部分
# ...浏览器设置
- name: Visual Regression Testing
run: npm run test:visual更新工作流
有趣的地方在这里。你不希望在每个 PR 上自动更新截图 (混乱!)。相反,创建一个 手动触发的工作流,开发人员可以在有意更改 UI 时运行它。
下面的工作流:
- 仅在功能分支上运行(绝不在 main 上)
- 将触发者列为共同作者
- 防止同一分支上的并发运行
- 显示漂亮的摘要:
当截图发生变化时,它会列出变化内容


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


TIP
这只是一种方法。有些团队更喜欢 PR 评论(/update-screenshots), others 使用标签。调整它以适合你的工作流!
重要的是要有一种受控的方式来更新基线。
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那么选哪一个?
两种方法都有效。真正的问题是哪些痛点对你的 团队最重要。
如果你已经深入 GitHub 生态系统,GitHub Actions 很难被击败。 对开源免费,适用于任何浏览器提供商,并且你控制 一切。
缺点?当有人在本地生成 截图且它们不再符合 CI 预期时,会出现“在我机器上是好的”这种对话。
如果开发人员需要在本地运行视觉测试,云服务就有意义了。
有些团队让设计师检查工作,或者开发人员更喜欢在 推送之前发现问题。它允许跳过 推送 - 等待 - 检查 - 修复 - 推送 循环。
还在犹豫?从 GitHub Actions 开始。如果本地测试成为 痛点,你以后可以随时添加云服务。
