Vue 基础体系 · 第 52/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue 视觉回归测试:截图基线、字体、动画、阈值和审阅
视觉回归测试用于回答一个具体问题:
在代码变更之后,同一个页面在规定的环境中,渲染结果是否仍然符合已经确认过的视觉结果?
它测试的不是 Vue 组件是否返回了正确的数据,也不是点击按钮后是否调用了某个函数,而是浏览器最终生成的像素是否发生了不应发生的变化。
在 Vue 3 项目中,视觉回归测试通常位于以下链路的末端:
Vue 组件
↓
Vite 构建与开发服务器
↓
浏览器加载 HTML、CSS、字体和资源
↓
布局、绘制、动画、合成
↓
截图
↓
与截图基线比较
↓
通过、失败或提交人工审阅
因此,截图差异不一定意味着 Vue 代码错误。字体加载失败、浏览器版本改变、动画尚未结束、设备像素比不同,都会改变截图。视觉回归测试的核心不是“把阈值调大直到通过”,而是建立一个可重复的渲染条件,并对差异进行有依据的判断。
一、视觉回归测试到底测试什么
1. 渲染结果是环境和状态的函数
可以把一次截图抽象为:
其中:
- :最终截图;
- :浏览器渲染过程;
- :代码,包括 Vue 模板、脚本和 CSS;
- :页面状态,例如登录状态、表单值、展开状态;
- :渲染环境,例如浏览器版本、操作系统、视口、字体;
- :截图时刻。
视觉回归比较的不是任意两张图片,而是:
与:
之间的差异。
这说明视觉测试要稳定,至少需要控制四件事:
- 代码状态固定:页面使用确定的组件和样式;
- 数据状态固定:不要依赖实时接口、当前时间或随机数;
- 渲染环境固定:浏览器、视口、字体和设备像素比尽量固定;
- 截图时机固定:等待页面完成加载和必要的异步渲染。
如果其中任意一项不稳定,测试就可能在代码没有变化时失败,这类失败通常称为视觉噪声。
2. 视觉回归不等于端到端测试
端到端测试关注行为:
点击“展开” → 面板出现 → 文本可见
视觉回归测试关注结果:
展开后的面板、间距、颜色、边框和字体是否符合基线
两者可以使用同一个测试框架,但断言目标不同:
await expect(page.getByRole('button', { name: '展开详情' }))
.toBeVisible()
await page.getByRole('button', { name: '展开详情' }).click()
await expect(page.locator('[data-testid="details-panel"]'))
.toHaveScreenshot('details-panel-expanded.png')
第一条断言验证元素存在,第二条断言验证元素的视觉输出。
视觉测试不能代替行为测试。例如,一个按钮可能视觉上完全正确,但点击事件没有绑定;反过来,按钮功能正确,也可能因为 CSS 改动导致布局错位。
二、截图基线:什么被保存,什么被比较
1. 基线的定义
截图基线是经过确认的参考图像。测试运行时生成当前截图,再将它与基线进行比较。
用集合表示:
- 基线像素集合:
- 当前截图像素集合:
- 差异像素集合:
理想情况下:
其中:
- :像素位置;
- 、:该位置的基线像素和当前像素;
- :像素颜色差异函数;
- :单像素差异阈值。
如果 为空,说明没有超过单像素阈值的差异。但实际工具通常还允许一定数量的差异像素,因此最终判定还会使用:
其中 是允许的最大差异像素数。
也可以按比例判断:
其中:
- :截图宽度;
- :截图高度;
- :允许的差异像素比例。
这三个概念必须区分:
| 参数 | 作用 | 适合控制什么 |
|---|---|---|
| 单像素阈值 | 一个像素的颜色差异多大才算不同 | 抗锯齿、轻微颜色误差 |
| 最大差异像素数 | 最多允许多少个差异像素 | 少量局部噪声 |
| 最大差异比例 | 差异像素占整图的比例 | 不同尺寸截图的相对容忍度 |
阈值过低,会把渲染器的抗锯齿差异当成缺陷;阈值过高,则可能掩盖真实的边框、间距或颜色变化。
2. 基线不是“期望图片”的绝对真理
基线只是某个特定环境下被审阅并接受的结果。它不是设计稿,也不是跨平台通用的客观标准。
例如,以下两张截图都可能是正确结果:
- Linux 上使用 Noto Sans 渲染;
- macOS 上使用系统字体渲染。
它们的字形宽度和抗锯齿方式可能不同,但这不代表应用逻辑有错。因此,常见做法是为视觉测试固定一个渲染环境,而不是让每个开发者在本机生成和提交不同基线。
3. 基线目录和命名必须表达测试语义
以 Playwright Test 为例,截图基线通常由测试名称、项目名称和截图名称共同决定。下面的命名比 test.png 更容易审阅:
await expect(page).toHaveScreenshot('dashboard-empty.png')
建议让截图名称描述:
- 页面或组件;
- 状态;
- 视口或主题差异,如果测试中存在多个状态。
例如:
dashboard-empty.png
dashboard-with-errors.png
dialog-confirm-delete.png
navigation-mobile-open.png
不要把状态编码在含义不清的序号里:
screen-1.png
screen-2.png
因为当测试失败时,审阅者首先需要知道“这张图应该代表什么”。
三、使用 Vue 3、Vite 和 Playwright 建立最小可运行示例
下面的示例基于 Vue 3、Composition API、TypeScript 和 Vite。Vite 负责开发服务器和构建,Vue 官方工具链文档也将 Vite 作为现代 Vue 项目的常见基础工具。视觉断言则使用 Playwright Test;Playwright 不是 Vue 或 Vite 的内置能力,需要单独安装。
1. 安装测试工具
在已有的 Vite Vue 项目中执行:
npm install -D @playwright/test
npx playwright install chromium
这两条命令分别完成:
- 安装 Playwright Test;
- 安装测试所需的 Chromium 浏览器。
如果项目使用 pnpm 或 yarn,应使用对应包管理器,但命令语义相同。
检查安装是否成功:
npx playwright test --version
预期输出是一个版本号。具体版本会随安装时间变化,因此不应在文章或脚本中假定某个固定版本号。
2. 准备一个确定性 Vue 页面
src/App.vue:
<script setup lang="ts">
import { computed, ref } from 'vue'
interface Product {
id: number
name: string
price: number
stock: number
}
const products: Product[] = [
{ id: 1, name: '机械键盘', price: 699, stock: 12 },
{ id: 2, name: '人体工学鼠标', price: 399, stock: 0 },
{ id: 3, name: 'USB-C 扩展坞', price: 299, stock: 7 },
]
const showOnlyAvailable = ref(false)
const visibleProducts = computed(() => {
if (!showOnlyAvailable.value) {
return products
}
return products.filter((product) => product.stock > 0)
})
</script>
<template>
<main class="page-shell">
<header class="page-header">
<div>
<p class="eyebrow">商品目录</p>
<h1>办公设备</h1>
</div>
<label class="filter">
<input v-model="showOnlyAvailable" type="checkbox" />
只显示有库存
</label>
</header>
<section class="product-grid" aria-label="商品列表">
<article
v-for="product in visibleProducts"
:key="product.id"
class="product-card"
:class="{ 'product-card--sold-out': product.stock === 0 }"
>
<div class="product-card__content">
<h2>{{ product.name }}</h2>
<p class="price">¥{{ product.price }}</p>
</div>
<span v-if="product.stock > 0" class="badge badge--available">
有库存:{{ product.stock }}
</span>
<span v-else class="badge badge--sold-out">
暂时缺货
</span>
</article>
</section>
</main>
</template>
<style scoped>
:global(*) {
box-sizing: border-box;
}
:global(body) {
margin: 0;
background: #f4f6f8;
color: #17202a;
font-family: Inter, "Noto Sans SC", Arial, sans-serif;
}
.page-shell {
width: min(960px, calc(100% - 48px));
margin: 64px auto;
}
.page-header {
display: flex;
align-items: end;
justify-content: space-between;
gap: 24px;
margin-bottom: 24px;
}
.eyebrow {
margin: 0 0 8px;
color: #5b6b7a;
font-size: 14px;
}
h1,
h2 {
margin: 0;
}
h1 {
font-size: 36px;
line-height: 1.2;
}
.filter {
display: flex;
align-items: center;
gap: 8px;
color: #34495e;
font-size: 14px;
}
.product-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.product-card {
min-height: 164px;
padding: 20px;
border: 1px solid #dce3e8;
border-radius: 12px;
background: #ffffff;
box-shadow: 0 4px 12px rgb(23 32 42 / 6%);
}
.product-card--sold-out {
background: #fafafa;
}
.product-card__content {
min-height: 92px;
}
h2 {
font-size: 18px;
line-height: 1.4;
}
.price {
margin: 16px 0 0;
color: #1769aa;
font-size: 22px;
font-weight: 700;
}
.badge {
display: inline-flex;
padding: 4px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.badge--available {
color: #146c43;
background: #d1e7dd;
}
.badge--sold-out {
color: #842029;
background: #f8d7da;
}
@media (max-width: 700px) {
.page-shell {
width: min(100% - 32px, 520px);
margin: 32px auto;
}
.page-header {
align-items: start;
flex-direction: column;
}
.product-grid {
grid-template-columns: 1fr;
}
}
</style>
这个页面有一个关键特征:商品数据是静态常量,没有当前时间、随机数或实时请求。因此,在相同浏览器环境下,它的默认状态应该能够稳定截图。
3. 配置 Vite 开发服务器
playwright.config.ts:
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests',
timeout: 30_000,
expect: {
timeout: 5_000,
toHaveScreenshot: {
animations: 'disabled',
caret: 'hide',
scale: 'css',
},
},
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? 'github' : 'list',
use: {
baseURL: 'http://127.0.0.1:4173',
browserName: 'chromium',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium-desktop',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1280, height: 720 },
deviceScaleFactor: 1,
},
},
{
name: 'chromium-mobile',
use: {
...devices['Pixel 5'],
viewport: { width: 393, height: 851 },
deviceScaleFactor: 1,
isMobile: true,
},
},
],
webServer: {
command: 'npm run dev -- --host 127.0.0.1 --port 4173',
url: 'http://127.0.0.1:4173',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
})
这里有几个容易混淆的配置:
viewport是 CSS 像素尺寸,影响媒体查询和布局;deviceScaleFactor影响 CSS 像素映射到设备像素的方式;scale: 'css'表示截图按 CSS 像素输出,有助于减少不同设备像素比带来的尺寸差异;baseURL允许测试使用相对路径;webServer会在测试前启动 Vite 开发服务器;reuseExistingServer在本地可复用已有服务,但 CI 中通常应让测试进程控制服务器生命周期。
Desktop Chrome、Pixel 5 等设备描述和 toHaveScreenshot 的具体选项属于 Playwright API,不是 Vue 或 Vite 的规范保证。安装的 Playwright 版本应与项目锁定文件一起管理,并在升级时重新生成和审阅基线。
4. 编写端到端截图测试
tests/catalog.visual.spec.ts:
import { expect, test } from '@playwright/test'
test.describe('商品目录视觉回归', () => {
test('桌面端默认状态', async ({ page }) => {
await page.goto('/', { waitUntil: 'networkidle' })
await page.evaluate(async () => {
await document.fonts.ready
})
await expect(page).toHaveScreenshot('catalog-default.png', {
fullPage: true,
})
})
test('桌面端只显示有库存', async ({ page }) => {
await page.goto('/', { waitUntil: 'networkidle' })
await page.getByLabel('只显示有库存').check()
await expect(page).toHaveScreenshot('catalog-available-only.png', {
fullPage: true,
})
})
test('移动端默认状态', async ({ page }) => {
await page.goto('/', { waitUntil: 'networkidle' })
await page.evaluate(async () => {
await document.fonts.ready
})
await expect(page).toHaveScreenshot('catalog-mobile-default.png', {
fullPage: true,
})
})
})
执行测试:
npx playwright test
第一次执行时,如果没有对应基线,测试通常会失败并提示缺少快照。此时应在确认页面状态和运行环境正确后生成基线:
npx playwright test --update-snapshots
也可以只生成桌面项目:
npx playwright test --project=chromium-desktop --update-snapshots
更新基线不是“修复测试失败”的通用命令。它会把当前结果写成新的期望结果,因此应先判断当前页面是否确实符合需求,再执行更新。
四、字体:最常见、也最容易误判的差异来源
1. 字体变化会改变布局,不只是字形
字体至少影响:
- 每个字符的宽度;
- 行高和基线;
- 文本换行位置;
- 按钮和卡片的高度;
- 字体粗细;
- 抗锯齿和像素边缘。
例如,一个标题在字体 A 中宽度为 286 CSS 像素,在字体 B 中宽度为 304 CSS 像素。若容器宽度为 300 CSS 像素:
字体 A:标题保持一行
字体 B:标题换成两行
这不再是局部字形差异,而会导致整个卡片、网格甚至页面高度改变。
CSS 的字体回退链:
font-family: Inter, "Noto Sans SC", Arial, sans-serif;
并不保证每台机器实际使用同一个字体。它只表示浏览器按顺序寻找可用字体。若 CI 环境没有 Inter 或 Noto Sans SC,浏览器会继续使用下一个字体。
2. 字体加载完成不等于字体存在
下面的等待只能保证当前页面发起的字体加载已经完成:
await page.evaluate(async () => {
await document.fonts.ready
})
它不能保证 CSS 指定的字体文件一定存在,也不能保证某个字体没有因为网络错误而回退。
因此,生产测试环境应采用至少一种明确策略:
策略 A:把字体文件放入仓库
@font-face {
font-family: "WR Sans";
src: url("/fonts/wr-sans-regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: block;
}
:root {
font-family: "WR Sans", sans-serif;
}
字体文件由应用静态提供,基线环境不依赖开发者电脑是否安装字体。
策略 B:在测试环境使用固定系统镜像
例如所有 CI 视觉测试都使用相同的容器镜像和浏览器版本。此时本地开发机可以不同,但本地截图不应直接作为 CI 基线来源。
策略 C:显式检查字体是否可用
const fontAvailable = await page.evaluate(() => {
return document.fonts.check('400 16px "WR Sans"')
})
expect(fontAvailable).toBe(true)
document.fonts.check() 返回 true 说明浏览器认为该字体组合可用,但它仍然不是对字形内容和版本的完整校验。字体文件升级后,即使字体名称不变,也可能改变截图。
3. 诊断字体导致的失败
字体问题通常具有这些特征:
- 页面上几乎所有文字都出现差异;
- 差异集中在字符轮廓和文本边缘;
- 文本换行位置改变;
- 失败发生在某个操作系统或 CI 环境,而不是所有环境;
- 盒模型边界发生连锁变化。
诊断步骤可以按以下顺序进行:
- 检查截图运行的浏览器项目和操作系统;
- 检查字体文件请求是否返回 200;
- 检查
document.fonts.check(); - 等待
document.fonts.ready; - 比较失败图中的换行和元素尺寸;
- 确认字体文件是否被升级或替换;
- 确认基线是否由同一环境生成。
如果差异只出现在文字边缘,先不要直接增大阈值。应该先确定是否发生了字体回退;否则阈值可能掩盖实际的布局变化。
五、动画:截图时刻不确定,基线就不稳定
1. 动画导致的是时间维度上的不确定性
假设一个元素的位置由动画决定:
其中:
- :初始位置;
- :最终位置;
- :随时间变化的进度函数。
如果测试在 截图,元素可能位于 30%;在 截图,可能位于 70%。即使代码完全没变,两次截图也会不同。
这类差异无法通过可靠地调整像素阈值解决,因为差异可能覆盖整块元素。
2. Playwright 的动画禁用选项
在配置中:
expect: {
toHaveScreenshot: {
animations: 'disabled',
},
}
这是 Playwright 截图断言提供的能力。根据 Playwright 的实现语义,禁用动画会处理 CSS 动画和过渡;有限时长动画可能被推进到结束状态,无限动画会被取消。具体行为应以项目锁定的 Playwright 版本文档和实际测试为准。
这项配置并不自动解决所有动画:
- JavaScript 通过
requestAnimationFrame修改样式; - Canvas 内部绘制动画;
- WebGL 场景变化;
- 第三方组件内部定时器;
- 服务端返回数据后才开始的动画。
因此更稳妥的方式是为应用提供测试模式。
3. 为 Vue 应用提供显式测试模式
应用入口可以根据环境变量设置属性:
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
const app = createApp(App)
if (import.meta.env.VITE_VISUAL_TEST === 'true') {
document.documentElement.dataset.visualTest = 'true'
}
app.mount('#app')
全局 CSS:
html[data-visual-test='true'] *,
html[data-visual-test='true'] *::before,
html[data-visual-test='true'] *::after {
animation: none !important;
transition: none !important;
caret-color: transparent !important;
}
测试服务器命令改为:
webServer: {
command:
'VITE_VISUAL_TEST=true npm run dev -- --host 127.0.0.1 --port 4173',
url: 'http://127.0.0.1:4173',
}
这样做的因果关系是明确的:
- Vite 将
VITE_VISUAL_TEST暴露给客户端; - 应用启动时添加
data-visual-test; - CSS 在该模式下关闭动画和过渡;
- 截图在一个固定的静态视觉状态上执行。
需要注意,VITE_* 变量会被暴露到客户端构建结果中,因此不能放置密钥。这里的变量只表示测试模式,不包含敏感信息。
4. 不要把动画全部禁用后就认为状态被覆盖
视觉测试必须选择具体状态。例如一个弹窗有以下状态:
关闭
打开后的静止状态
打开动画进行中
关闭动画进行中
如果测试目标是审阅弹窗打开后的布局,应测试“打开后的静止状态”,而不是随机截取打开动画中间的一帧。
如果动画本身是产品要求的一部分,可以单独测试:
- 使用固定时间控制动画;
- 通过
prefers-reduced-motion进入确定状态; - 测试开始和结束状态,而不是依赖任意时间点;
- 行为测试验证动画触发,视觉测试验证最终布局。
不能简单地对所有动画都截一张图,因为“动画正在运行”不是一个稳定的测试状态。
六、阈值:允许误差,但不能替缺陷辩护
1. 单像素阈值和差异面积是两个不同问题
考虑两个变更:
变更 A:大面积颜色轻微变化
100,000 个像素,每个像素只变化一点点
变更 B:一个按钮完全消失
2,000 个像素发生巨大变化
如果只设置单像素阈值,A 可能被忽略,但 B 仍可能失败;如果只设置最大差异像素数,B 可能因为面积较小而被放过。
所以阈值应同时考虑:
或者:
2. Playwright 中的阈值配置
示例:
await expect(page).toHaveScreenshot('catalog-default.png', {
fullPage: true,
threshold: 0.2,
maxDiffPixels: 100,
})
这里:
threshold控制单个像素颜色差异的容忍程度,范围是 0 到 1;maxDiffPixels控制允许的差异像素数量;fullPage让截图覆盖完整页面,而不只截当前视口。
threshold 越大,通常越宽松;maxDiffPixels 越大,允许的差异面积越大。但具体颜色比较算法和实现细节属于测试框架能力,不是 Web 标准,也不应将某个数值解释成“百分之多少颜色差异”。
一个常见的初始配置是:
expect: {
toHaveScreenshot: {
threshold: 0.2,
maxDiffPixels: 0,
},
}
这表示允许单像素比较存在一定颜色容忍,但不允许超过阈值的差异像素出现。是否适合项目,必须通过实际浏览器和 CI 环境验证。
3. 为什么不建议全局设置很大的阈值
假设一张 1280 × 720 的截图:
若允许差异比例为 :
九千多个差异像素足以覆盖一个按钮、一个图标,甚至一小段文字。对局部组件截图而言,这个比例可能更加危险,因为总像素数较小,局部缺陷占比会显得不同。
因此,阈值应和截图目标有关:
- 对固定浏览器、固定字体、固定 CI 镜像,尽量使用严格阈值;
- 对确实存在抗锯齿噪声的区域,优先固定环境;
- 如果必须放宽,只对具体断言设置,而不是全局放宽;
- 必须观察 diff 图,确认被放过的区域不会掩盖产品缺陷。
4. “差异很多但阈值很小”与“差异很少但阈值很大”的反例
反例一:阈值太小
浏览器版本升级后,文本边缘产生少量抗锯齿差异。页面结构不变,但测试在每个字符边缘产生几十万个轻微差异像素,导致失败。
正确处理顺序:
- 确认浏览器和操作系统是否变化;
- 确认字体是否变化;
- 确认截图缩放方式和设备像素比;
- 确认差异是否只位于边缘;
- 如果环境变化是有意的,重新生成该环境的基线。
反例二:阈值太大
开发者把 threshold 调到很高,测试通过,但按钮颜色从蓝色变成灰色。由于颜色差异和差异像素数都被过度放宽,缺陷没有被阻止。
这不是“视觉测试不适合颜色变化”,而是测试配置已经失去检测能力。
七、截图范围:整页、元素和局部状态
1. 整页截图适合审阅页面级布局
await expect(page).toHaveScreenshot('catalog-default.png', {
fullPage: true,
})
它可以发现:
- 页面高度变化;
- 内容溢出;
- 页脚位置变化;
- 多个区域之间的间距变化;
- 响应式断点错误。
但整页截图的缺点是一个小改动可能让整张图产生大量 diff,审阅成本较高。
2. 元素截图适合稳定的组件边界
const card = page.locator('.product-card').first()
await expect(card).toHaveScreenshot('product-card-available.png')
元素截图更适合:
- Button;
- Card;
- Dialog;
- Dropdown;
- 表格行;
- 独立状态的组件。
它减少了页面其他部分的噪声,但元素边界必须稳定。如果元素尺寸本身由动态内容决定,截图高度也会跟着变化。
3. mask 只适合明确的动态区域
await expect(page).toHaveScreenshot('profile.png', {
mask: [
page.getByTestId('avatar'),
page.getByTestId('last-login-time'),
],
maskColor: '#ff00ff',
})
mask 会用固定颜色覆盖指定区域,让头像或时间等动态内容不参与真实像素比较。
它不应被用来遮盖:
- 整个页面;
- 改动最大的区域;
- 业务核心内容;
- 无法解释的失败区域。
如果某个区域经常被 mask,更应该考虑为测试提供固定数据。例如把“最后登录时间”注入固定值,而不是永久隐藏它。
八、动态数据、网络请求与页面状态
1. 数据不稳定会制造伪差异
以下代码会破坏截图稳定性:
const now = new Date().toLocaleString()
const id = Math.random()
const items = await fetch('/api/items').then((response) => response.json())
问题分别是:
- 当前时间每次不同;
- 随机数每次不同;
- 网络响应可能变化、超时或排序不同。
应在测试中固定数据源。Playwright 可以拦截请求:
import { expect, test } from '@playwright/test'
test('固定接口数据的列表状态', async ({ page }) => {
await page.route('**/api/products', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: '机械键盘', price: 699, stock: 12 },
{ id: 2, name: '人体工学鼠标', price: 399, stock: 0 },
]),
})
})
await page.goto('/products', { waitUntil: 'networkidle' })
await expect(page.locator('[data-testid="product-list"]'))
.toHaveScreenshot('product-list-fixed-data.png')
})
每一步成立的原因是:
page.route()在请求发出时拦截匹配 URL;route.fulfill()返回固定响应;- 页面仍然通过真实的前端请求链路加载数据;
- 测试比较的是确定的列表状态,而不是某个线上环境的瞬时状态。
如果页面请求失败,测试应当明确失败,而不是通过空状态截图掩盖错误。可以先断言接口或错误提示:
await expect(page.getByTestId('product-list')).toBeVisible()
await expect(page.getByTestId('error-message')).toBeHidden()
视觉断言应该发生在页面已经达到预期业务状态之后。
2. 等待“网络空闲”不是万能等待
await page.goto('/', { waitUntil: 'networkidle' })
networkidle 只能表达网络活动暂时减少。它不能保证:
- Vue 异步组件已经完成渲染;
- 字体已真正应用;
requestAnimationFrame已停止;- WebSocket 不再发送消息;
- 组件内部定时器已经结束。
更可靠的等待条件是等待业务状态:
await page.goto('/products')
await expect(page.getByRole('heading', { name: '办公设备' }))
.toBeVisible()
await expect(page.getByTestId('product-list-loading'))
.toBeHidden()
await page.evaluate(async () => {
await document.fonts.ready
})
等待条件应与截图内容建立因果关系,而不是盲目增加 waitForTimeout:
// 不推荐:只是猜测 500 毫秒足够
await page.waitForTimeout(500)
固定睡眠时间在较慢的 CI 上可能不够,在较快环境上又会浪费时间,而且无法证明页面已经进入目标状态。
九、视觉测试的状态模型
一个页面不是只有“正常”和“异常”两种状态。视觉测试应先定义状态,再为具有视觉意义的状态建立基线。
例如一个对话框可以建模为:
Closed
└── click open → Opening
Opening
├── animation finished → Open
└── error → Closed
Open
├── click cancel → Closing
├── click confirm → Submitting
└── escape → Closing
Submitting
├── success → Closed
└── failure → OpenWithError
视觉基线通常选择稳定节点:
Open
OpenWithError
而不是:
Opening 的任意中间帧
Submitting 的任意网络时刻
对应测试可以这样写:
test('删除确认框的错误状态', async ({ page }) => {
await page.goto('/settings')
await page.getByRole('button', { name: '删除项目' }).click()
await expect(page.getByRole('dialog')).toBeVisible()
await page.getByRole('button', { name: '确认删除' }).click()
await expect(page.getByRole('dialog')).toContainText('删除失败')
await expect(page.getByRole('dialog'))
.toHaveScreenshot('delete-dialog-error.png')
})
这段测试先通过行为操作进入状态,再截图。截图断言不负责把页面推进到正确状态;它负责确认状态已经建立后的视觉结果。
十、失败结果如何阅读
一次截图失败通常会产生三类信息:
- 实际截图:本次测试生成的图;
- 基线截图:已保存的期望图;
- 差异图:工具标出不同区域的图。
应按差异形态诊断,而不是看到红色就立即更新基线。
1. 整体平移
可能原因:
body默认 margin 未清除;- 页面容器宽度改变;
- 视口尺寸不同;
- 滚动条出现或消失;
- 字体导致文本高度变化。
检查方法:
const box = await page.locator('.page-shell').boundingBox()
console.log(box)
将当前布局盒子的坐标和基线环境记录进行比较,可以区分“整个页面移动”和“局部组件变大”。
2. 文字区域大面积变化
可能原因:
- 字体回退;
- 字体加载时机错误;
- 字体文件升级;
- 字体粗细映射不同;
- 浏览器或操作系统变化。
优先检查字体,不要先提高 threshold。
3. 阴影或圆角边缘变化
可能原因:
- 浏览器渲染器变化;
- GPU 合成差异;
- CSS 阴影参数变化;
- 设备像素比变化。
如果只有少量边缘差异,可以评估单像素阈值;如果整个阴影范围都不同,应该先固定浏览器和渲染环境。
4. 元素内容随机变化
可能原因:
- 日期、时间、随机数;
- 接口数据变化;
- 排序不稳定;
- 缓存或登录状态不同;
- 测试之间共享了状态。
诊断时记录:
- URL;
- 测试项目名称;
- 浏览器版本;
- 视口;
- 设备像素比;
- 数据 fixture 版本;
- 字体检查结果。
这些信息比单独查看一张差异图更能解释失败来源。
十一、审阅:谁来决定基线是否应该更新
1. 基线更新是一次产品判断
当代码变更导致截图失败时,只有两种合理结果:
结果 A:当前结果是缺陷
例如:
- 间距意外从 16px 变成 8px;
- 文字颜色被错误覆盖;
- 移动端三列布局没有切换成单列;
- 错误提示被遮住;
- 按钮进入不可见状态。
此时应修改代码,不能更新基线。
结果 B:当前结果是有意变更
例如:
- 设计要求卡片圆角从 8px 改为 12px;
- 产品增加了新的状态徽标;
- 设计系统统一了主色;
- 页面结构经过确认后重新布局。
此时应在代码评审中说明视觉变化,再更新基线并提交图片变更。
2. 代码和基线必须同一变更提交
不建议单独提交“更新截图基线”的提交,除非它本身就是一次明确的基线迁移。
更容易审阅的变更包含:
修改组件样式
修改对应视觉测试
更新对应基线
在 PR 描述中说明视觉变化
这样审阅者可以判断:
- 代码改动是否导致了截图变化;
- 截图变化是否与需求一致;
- 是否有不相关区域同时发生变化;
- 是否误把测试环境问题写入基线。
3. 失败时必须保留证据
CI 中应保留:
- actual screenshot;
- expected screenshot;
- diff screenshot;
- trace;
- 测试日志。
上面的配置:
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
用于在失败时保留运行证据。它们不能代替截图比较,但能帮助判断截图发生在什么状态。
例如 trace 可以显示:
- 页面是否还在加载;
- 点击是否成功;
- 元素是否被遮挡;
- 失败前是否发生了异常请求;
- 测试是否进入了错误页面。
十二、用“视觉变更大小”辅助审阅
差异面积可以作为审阅信号,但不能直接作为自动批准条件。
定义差异比例:
如果测试框架报告:
diff pixels: 42
image pixels: 921600
则:
这可能是少量抗锯齿差异,也可能是一个非常重要的小图标被改坏。差异比例只能告诉你“变化面积有多大”,不能告诉你“变化是否合理”。
同样,一个页面背景颜色变化可能占据几乎整张图,但产品设计确实可能要求修改背景。因此审阅仍然需要结合:
- 差异位置;
- 组件语义;
- 需求说明;
- 代码改动;
- 浏览器和字体环境。
十三、常见误区和失败路径
误区一:在每台开发机上生成基线
不同操作系统的字体、浏览器渲染和系统控件都可能不同。结果是同一测试在开发者电脑上通过,在 CI 上失败,或者不同开发者提交互相覆盖的基线。
更稳定的流程是:
统一 CI 浏览器环境
↓
在该环境生成基线
↓
所有 PR 在同一环境比较
本地运行可以用于快速诊断,但不能默认本地截图就是最终基线。
误区二:用 waitForTimeout 解决所有闪烁
固定等待只改变时间,不改变状态保证。网络慢、字体慢或动画时长变化时,固定等待仍可能失败。
应等待可观察条件:
await expect(page.getByTestId('ready')).toHaveAttribute('data-state', 'done')
await page.evaluate(() => document.fonts.ready)
误区三:用很大的阈值“修复”不稳定测试
如果失败由字体回退、动画或随机数据引起,阈值不是根因修复。扩大阈值只会让真实缺陷更难发现。
阈值的正确用途是容忍已知且无法完全消除的像素级渲染差异,而不是容忍不确定的页面状态。
误区四:所有动态区域都 mask
过度 mask 会使测试看起来稳定,但实际没有覆盖关键内容。例如把整个表格 mask 后,列宽、字体和金额格式都不再被验证。
优先顺序通常应是:
固定数据
>
固定时间和随机源
>
固定动画状态
>
只 mask 无法合理固定的内容
误区五:只测默认状态
默认状态往往最简单,真实缺陷通常出现在:
- 空状态;
- 加载状态;
- 错误状态;
- 长文本;
- 无权限状态;
- 移动端断点;
- 弹窗打开状态;
- 表单校验失败状态;
- 深色主题或高对比度主题。
每一个状态都不需要建立整页截图,但具有独立视觉意义的状态应有对应的断言。
十四、适合生产使用的测试分层
视觉测试数量并不是越多越好。可以按覆盖范围分层:
1. 组件级截图
适合稳定、复用频繁的组件:
test('库存徽标', async ({ page }) => {
await page.goto('/component-preview')
await expect(page.getByTestId('stock-badge'))
.toHaveScreenshot('stock-badge.png')
})
优点是差异局部、定位快;缺点是组件预览页本身也需要维护。
2. 页面级截图
适合验证真实路由和页面布局:
test('商品目录页面', async ({ page }) => {
await page.goto('/products')
await expect(page).toHaveScreenshot('products-page.png', {
fullPage: true,
})
})
优点是覆盖真实组合关系;缺点是局部差异可能扩大为整页差异。
3. 关键流程状态截图
适合覆盖行为导致的视觉状态变化:
打开菜单
提交表单失败
删除确认框
无搜索结果
权限不足
这类测试数量应由产品风险决定,而不是机械地为每个点击都截图。
十五、版本敏感能力和环境边界
以下内容不是 Vue 3 或 Web 标准本身的保证:
- Playwright 的快照文件命名规则;
toHaveScreenshot的具体阈值算法;animations: 'disabled'的实现细节;- 浏览器项目中的设备描述;
- CI 镜像预装的系统字体;
- 不同浏览器版本的抗锯齿和文本渲染结果。
因此项目应:
- 锁定
@playwright/test版本; - 锁定浏览器安装版本或镜像;
- 在浏览器升级时集中更新基线;
- 将基线文件作为代码评审的一部分;
- 不把不同浏览器的截图混在同一个基线目录中;
- 在升级字体、操作系统或设备像素比后重新评估阈值。
浏览器升级导致全量基线变化时,不能简单地认为“工具坏了”。应先抽样检查:
- 页面结构是否保持;
- 字体是否保持;
- 差异是否主要在文本和阴影边缘;
- 是否出现真实布局变化;
- 新浏览器是否成为项目正式支持环境。
十六、一个完整的执行与恢复流程
首次建立基线
npm install
npx playwright install chromium
npx playwright test --project=chromium-desktop
npx playwright test --project=chromium-desktop --update-snapshots
npx playwright test
预期过程:
- 第一次执行没有基线,测试失败;
- 确认页面、数据、字体和环境正确;
- 使用
--update-snapshots生成基线; - 再次执行,测试应通过;
- 将基线文件加入版本控制。
代码变更后失败
npx playwright test --project=chromium-desktop
恢复步骤:
- 打开 actual、expected 和 diff;
- 判断差异是缺陷、预期变更还是环境噪声;
- 如果是缺陷,修改代码并重新测试;
- 如果是预期变更,审阅代码和图片;
- 只有确认后才执行:
npx playwright test --project=chromium-desktop --update-snapshots
- 再次运行完整测试:
npx playwright test
- 确认桌面和移动项目都没有非预期变化。
如果误更新了基线,可以从版本控制恢复:
git restore tests
git restore snapshots
实际目录名称取决于项目配置。恢复前应先用 git status 确认不会覆盖其他尚未提交的测试修改。
十七、最终判断标准
一套可靠的 Vue 视觉回归测试,不是拥有大量 PNG 文件,而是满足以下因果链:
固定页面状态
↓
固定数据、字体、浏览器和视口
↓
等待 Vue、字体和必要资源完成
↓
关闭或控制动画
↓
生成当前截图
↓
使用明确的像素阈值和面积阈值比较
↓
保留差异证据
↓
由代码评审决定是否接受基线变化
其中最容易被忽略的是最后一步:测试通过不等于视觉结果正确,测试失败也不等于代码错误。
基线负责记录已接受的结果,字体和动画负责决定结果是否可重复,阈值负责定义“多大差异算失败”,审阅负责判断差异是否符合意图。只有这几个部分同时成立,截图比较才从“图片文件比对”变成了真正有工程价值的视觉回归测试。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue 端到端测试:Playwright、登录态、网络、并行和失败证据
- 下一篇:Vue Storybook:Story、交互测试、文档、主题和组件评审
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论