Vue 基础体系 · 第 52/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 视觉回归测试:截图基线、字体、动画、阈值和审阅

视觉回归测试用于回答一个具体问题:

在代码变更之后,同一个页面在规定的环境中,渲染结果是否仍然符合已经确认过的视觉结果?

它测试的不是 Vue 组件是否返回了正确的数据,也不是点击按钮后是否调用了某个函数,而是浏览器最终生成的像素是否发生了不应发生的变化。

在 Vue 3 项目中,视觉回归测试通常位于以下链路的末端:

Vue 组件
  ↓
Vite 构建与开发服务器
  ↓
浏览器加载 HTML、CSS、字体和资源
  ↓
布局、绘制、动画、合成
  ↓
截图
  ↓
与截图基线比较
  ↓
通过、失败或提交人工审阅

因此,截图差异不一定意味着 Vue 代码错误。字体加载失败、浏览器版本改变、动画尚未结束、设备像素比不同,都会改变截图。视觉回归测试的核心不是“把阈值调大直到通过”,而是建立一个可重复的渲染条件,并对差异进行有依据的判断。


一、视觉回归测试到底测试什么

1. 渲染结果是环境和状态的函数

可以把一次截图抽象为:

I=R(C,S,E,T)I = R(C, S, E, T)

其中:

  • II:最终截图;
  • RR:浏览器渲染过程;
  • CC:代码,包括 Vue 模板、脚本和 CSS;
  • SS:页面状态,例如登录状态、表单值、展开状态;
  • EE:渲染环境,例如浏览器版本、操作系统、视口、字体;
  • TT:截图时刻。

视觉回归比较的不是任意两张图片,而是:

Icurrent=R(Cnew,S,E,T)I_{\text{current}} = R(C_{\text{new}}, S, E, T)

与:

Ibaseline=R(Capproved,S,E,T)I_{\text{baseline}} = R(C_{\text{approved}}, S, E, T)

之间的差异。

这说明视觉测试要稳定,至少需要控制四件事:

  1. 代码状态固定:页面使用确定的组件和样式;
  2. 数据状态固定:不要依赖实时接口、当前时间或随机数;
  3. 渲染环境固定:浏览器、视口、字体和设备像素比尽量固定;
  4. 截图时机固定:等待页面完成加载和必要的异步渲染。

如果其中任意一项不稳定,测试就可能在代码没有变化时失败,这类失败通常称为视觉噪声


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. 基线的定义

截图基线是经过确认的参考图像。测试运行时生成当前截图,再将它与基线进行比较。

用集合表示:

  • 基线像素集合:BB
  • 当前截图像素集合:CC
  • 差异像素集合:DD

理想情况下:

D={pdiff(Bp,Cp)>τ}D = \{p \mid \operatorname{diff}(B_p, C_p) > \tau\}

其中:

  • pp:像素位置;
  • BpB_pCpC_p:该位置的基线像素和当前像素;
  • diff\operatorname{diff}:像素颜色差异函数;
  • τ\tau:单像素差异阈值。

如果 DD 为空,说明没有超过单像素阈值的差异。但实际工具通常还允许一定数量的差异像素,因此最终判定还会使用:

DM|D| \leq M

其中 MM 是允许的最大差异像素数。

也可以按比例判断:

DW×Hρ\frac{|D|}{W \times H} \leq \rho

其中:

  • WW:截图宽度;
  • HH:截图高度;
  • ρ\rho:允许的差异像素比例。

这三个概念必须区分:

参数 作用 适合控制什么
单像素阈值 一个像素的颜色差异多大才算不同 抗锯齿、轻微颜色误差
最大差异像素数 最多允许多少个差异像素 少量局部噪声
最大差异比例 差异像素占整图的比例 不同尺寸截图的相对容忍度

阈值过低,会把渲染器的抗锯齿差异当成缺陷;阈值过高,则可能掩盖真实的边框、间距或颜色变化。


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

这两条命令分别完成:

  1. 安装 Playwright Test;
  2. 安装测试所需的 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 ChromePixel 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 环境没有 InterNoto 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 环境,而不是所有环境;
  • 盒模型边界发生连锁变化。

诊断步骤可以按以下顺序进行:

  1. 检查截图运行的浏览器项目和操作系统;
  2. 检查字体文件请求是否返回 200;
  3. 检查 document.fonts.check()
  4. 等待 document.fonts.ready
  5. 比较失败图中的换行和元素尺寸;
  6. 确认字体文件是否被升级或替换;
  7. 确认基线是否由同一环境生成。

如果差异只出现在文字边缘,先不要直接增大阈值。应该先确定是否发生了字体回退;否则阈值可能掩盖实际的布局变化。


五、动画:截图时刻不确定,基线就不稳定

1. 动画导致的是时间维度上的不确定性

假设一个元素的位置由动画决定:

x(t)=x0+(x1x0)f(t)x(t) = x_0 + (x_1 - x_0) \cdot f(t)

其中:

  • x0x_0:初始位置;
  • x1x_1:最终位置;
  • f(t)f(t):随时间变化的进度函数。

如果测试在 t1t_1 截图,元素可能位于 30%;在 t2t_2 截图,可能位于 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',
}

这样做的因果关系是明确的:

  1. Vite 将 VITE_VISUAL_TEST 暴露给客户端;
  2. 应用启动时添加 data-visual-test
  3. CSS 在该模式下关闭动画和过渡;
  4. 截图在一个固定的静态视觉状态上执行。

需要注意,VITE_* 变量会被暴露到客户端构建结果中,因此不能放置密钥。这里的变量只表示测试模式,不包含敏感信息。


4. 不要把动画全部禁用后就认为状态被覆盖

视觉测试必须选择具体状态。例如一个弹窗有以下状态:

关闭
打开后的静止状态
打开动画进行中
关闭动画进行中

如果测试目标是审阅弹窗打开后的布局,应测试“打开后的静止状态”,而不是随机截取打开动画中间的一帧。

如果动画本身是产品要求的一部分,可以单独测试:

  • 使用固定时间控制动画;
  • 通过 prefers-reduced-motion 进入确定状态;
  • 测试开始和结束状态,而不是依赖任意时间点;
  • 行为测试验证动画触发,视觉测试验证最终布局。

不能简单地对所有动画都截一张图,因为“动画正在运行”不是一个稳定的测试状态。


六、阈值:允许误差,但不能替缺陷辩护

1. 单像素阈值和差异面积是两个不同问题

考虑两个变更:

变更 A:大面积颜色轻微变化

100,000 个像素,每个像素只变化一点点

变更 B:一个按钮完全消失

2,000 个像素发生巨大变化

如果只设置单像素阈值,A 可能被忽略,但 B 仍可能失败;如果只设置最大差异像素数,B 可能因为面积较小而被放过。

所以阈值应同时考虑:

通过=(每个差异达到判定标准)(DM)\text{通过} = (\text{每个差异达到判定标准}) \land (|D| \leq M)

或者:

通过=(每个差异达到判定标准)(DWHρ)\text{通过} = (\text{每个差异达到判定标准}) \land \left(\frac{|D|}{W H} \leq \rho\right)


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 的截图:

WH=921600W H = 921600

若允许差异比例为 1%1\%

921600×1%=9216921600 \times 1\% = 9216

九千多个差异像素足以覆盖一个按钮、一个图标,甚至一小段文字。对局部组件截图而言,这个比例可能更加危险,因为总像素数较小,局部缺陷占比会显得不同。

因此,阈值应和截图目标有关:

  • 对固定浏览器、固定字体、固定 CI 镜像,尽量使用严格阈值;
  • 对确实存在抗锯齿噪声的区域,优先固定环境;
  • 如果必须放宽,只对具体断言设置,而不是全局放宽;
  • 必须观察 diff 图,确认被放过的区域不会掩盖产品缺陷。

4. “差异很多但阈值很小”与“差异很少但阈值很大”的反例

反例一:阈值太小

浏览器版本升级后,文本边缘产生少量抗锯齿差异。页面结构不变,但测试在每个字符边缘产生几十万个轻微差异像素,导致失败。

正确处理顺序:

  1. 确认浏览器和操作系统是否变化;
  2. 确认字体是否变化;
  3. 确认截图缩放方式和设备像素比;
  4. 确认差异是否只位于边缘;
  5. 如果环境变化是有意的,重新生成该环境的基线。

反例二:阈值太大

开发者把 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')
})

每一步成立的原因是:

  1. page.route() 在请求发出时拦截匹配 URL;
  2. route.fulfill() 返回固定响应;
  3. 页面仍然通过真实的前端请求链路加载数据;
  4. 测试比较的是确定的列表状态,而不是某个线上环境的瞬时状态。

如果页面请求失败,测试应当明确失败,而不是通过空状态截图掩盖错误。可以先断言接口或错误提示:

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. 实际截图:本次测试生成的图;
  2. 基线截图:已保存的期望图;
  3. 差异图:工具标出不同区域的图。

应按差异形态诊断,而不是看到红色就立即更新基线。

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 可以显示:

  • 页面是否还在加载;
  • 点击是否成功;
  • 元素是否被遮挡;
  • 失败前是否发生了异常请求;
  • 测试是否进入了错误页面。

十二、用“视觉变更大小”辅助审阅

差异面积可以作为审阅信号,但不能直接作为自动批准条件。

定义差异比例:

r=DW×Hr = \frac{|D|}{W \times H}

如果测试框架报告:

diff pixels: 42
image pixels: 921600

则:

r=429216000.0046%r = \frac{42}{921600} \approx 0.0046\%

这可能是少量抗锯齿差异,也可能是一个非常重要的小图标被改坏。差异比例只能告诉你“变化面积有多大”,不能告诉你“变化是否合理”。

同样,一个页面背景颜色变化可能占据几乎整张图,但产品设计确实可能要求修改背景。因此审阅仍然需要结合:

  • 差异位置;
  • 组件语义;
  • 需求说明;
  • 代码改动;
  • 浏览器和字体环境。

十三、常见误区和失败路径

误区一:在每台开发机上生成基线

不同操作系统的字体、浏览器渲染和系统控件都可能不同。结果是同一测试在开发者电脑上通过,在 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 镜像预装的系统字体;
  • 不同浏览器版本的抗锯齿和文本渲染结果。

因此项目应:

  1. 锁定 @playwright/test 版本;
  2. 锁定浏览器安装版本或镜像;
  3. 在浏览器升级时集中更新基线;
  4. 将基线文件作为代码评审的一部分;
  5. 不把不同浏览器的截图混在同一个基线目录中;
  6. 在升级字体、操作系统或设备像素比后重新评估阈值。

浏览器升级导致全量基线变化时,不能简单地认为“工具坏了”。应先抽样检查:

  • 页面结构是否保持;
  • 字体是否保持;
  • 差异是否主要在文本和阴影边缘;
  • 是否出现真实布局变化;
  • 新浏览器是否成为项目正式支持环境。

十六、一个完整的执行与恢复流程

首次建立基线

npm install
npx playwright install chromium
npx playwright test --project=chromium-desktop
npx playwright test --project=chromium-desktop --update-snapshots
npx playwright test

预期过程:

  1. 第一次执行没有基线,测试失败;
  2. 确认页面、数据、字体和环境正确;
  3. 使用 --update-snapshots 生成基线;
  4. 再次执行,测试应通过;
  5. 将基线文件加入版本控制。

代码变更后失败

npx playwright test --project=chromium-desktop

恢复步骤:

  1. 打开 actual、expected 和 diff;
  2. 判断差异是缺陷、预期变更还是环境噪声;
  3. 如果是缺陷,修改代码并重新测试;
  4. 如果是预期变更,审阅代码和图片;
  5. 只有确认后才执行:
npx playwright test --project=chromium-desktop --update-snapshots
  1. 再次运行完整测试:
npx playwright test
  1. 确认桌面和移动项目都没有非预期变化。

如果误更新了基线,可以从版本控制恢复:

git restore tests
git restore snapshots

实际目录名称取决于项目配置。恢复前应先用 git status 确认不会覆盖其他尚未提交的测试修改。


十七、最终判断标准

一套可靠的 Vue 视觉回归测试,不是拥有大量 PNG 文件,而是满足以下因果链:

固定页面状态
  ↓
固定数据、字体、浏览器和视口
  ↓
等待 Vue、字体和必要资源完成
  ↓
关闭或控制动画
  ↓
生成当前截图
  ↓
使用明确的像素阈值和面积阈值比较
  ↓
保留差异证据
  ↓
由代码评审决定是否接受基线变化

其中最容易被忽略的是最后一步:测试通过不等于视觉结果正确,测试失败也不等于代码错误

基线负责记录已接受的结果,字体和动画负责决定结果是否可重复,阈值负责定义“多大差异算失败”,审阅负责判断差异是否符合意图。只有这几个部分同时成立,截图比较才从“图片文件比对”变成了真正有工程价值的视觉回归测试。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。