React 基础体系 · 第 52/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

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

视觉回归测试用于回答一个具体问题:在输入、浏览器环境和页面状态相同的前提下,本次代码变更是否改变了用户看到的像素结果

它与单元测试、组件测试的关注点不同:

  • 单元测试验证函数或状态转换是否符合预期;
  • DOM 测试验证元素、属性和文本是否存在;
  • 视觉回归测试验证最终渲染结果是否发生了可接受范围之外的变化。

React 本身没有“截图基线”或“视觉差异阈值”的 API。React 负责生成和更新 UI,截图通常由 Playwright、Cypress 等浏览器自动化工具完成,图像比较则由测试工具或专用服务完成。因此,视觉回归的结果不仅受 React 代码影响,也受浏览器版本、操作系统、字体、动画时刻、网络数据和设备像素比影响。


一、先定义视觉回归中的几个核心对象

1. 被测页面不是一个纯函数

可以把某个页面的截图抽象为:

I=R(C,V,D,E,T)I = R(C, V, D, E, T)

其中:

  • II:最终截图;
  • RR:浏览器渲染过程;
  • CC:组件代码和 CSS;
  • VV:视口尺寸、设备像素比、颜色方案等视觉环境;
  • DD:页面数据和用户状态;
  • EE:浏览器、操作系统、字体和渲染引擎;
  • TT:截图发生的时刻。

视觉回归想比较的是两个图像:

Iactual=R(Cnew,V,D,E,T)I_{\text{actual}} = R(C_{\text{new}}, V, D, E, T)

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

理想情况下,除了代码版本外,其余变量都应该保持不变。如果字体从 Arial 变成系统默认字体,或者截图时刻从动画第 100 毫秒变成第 300 毫秒,那么图像变化未必来自 React 代码。

2. 截图基线是什么

**截图基线(screenshot baseline)**是经过审阅并被接受的参考图像。它不是“正确 UI”的抽象描述,而是某个明确环境下的具体像素结果。

一个可靠的基线至少应绑定这些条件:

  • 浏览器名称和版本;
  • 操作系统或容器镜像;
  • 视口宽高;
  • 设备像素比;
  • 字体文件;
  • 颜色方案;
  • 页面数据和登录状态;
  • 页面加载完成和截图时机;
  • 截图范围,是整页还是某个组件。

因此,home-page.png 这样的文件名信息不足。更明确的组织方式可以是:

tests/
  visual/
    home/
      chromium-linux-1280x720-light.png
      dashboard/
        authenticated-chromium-linux-1440x900.png

在实际项目中,基线通常与测试代码一起提交到版本库,或者存储在专门的视觉测试服务中。基线更新本身应当被代码审查,而不是在 CI 失败后无条件覆盖旧图片。

3. 差异图是什么

视觉测试一般会生成三类结果:

  1. 实际图(actual):当前代码产生的截图;
  2. 基线图(expected/baseline):已批准的截图;
  3. 差异图(diff):标记两个图像不同的位置。

差异图只能说明“哪里不同”,不能直接说明“为什么不同”。例如,整页文字都向右偏移 2 像素,差异图会出现两侧成对的色块;这通常暗示字体、宽度或布局发生变化,而不是某个按钮颜色改变。


二、一个可运行的 React + Playwright 视觉测试

下面的例子使用 React、TypeScript 和 Playwright。React 版本可以是 React 19;截图能力来自 Playwright,而不是 React。

假设页面组件如下:

// src/App.tsx
export function App() {
  return (
    <main className="page">
      <section className="hero" aria-labelledby="title">
        <p className="eyebrow">工程质量</p>
        <h1 id="title">视觉回归测试</h1>
        <p className="description">
          在稳定的浏览器环境中,比较当前页面和经过审阅的截图基线。
        </p>
        <button type="button">开始检查</button>
      </section>
    </main>
  );
}
/* src/styles.css */
:root {
  font-family: "Inter", Arial, sans-serif;
  color: #172033;
  background: #f5f7fb;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
}

.page {
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 48px;
}

.hero {
  width: min(100%, 640px);
  padding: 48px;
  border: 1px solid #dbe2ef;
  border-radius: 20px;
  background: white;
  box-shadow: 0 12px 32px rgb(23 32 51 / 8%);
}

.eyebrow {
  margin: 0 0 12px;
  color: #5267d8;
  font-size: 14px;
  font-weight: 700;
}

h1 {
  margin: 0;
  font-size: 42px;
  line-height: 1.1;
}

.description {
  margin: 20px 0 28px;
  color: #59657a;
  line-height: 1.6;
}

Playwright 配置:

// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./tests",
  use: {
    baseURL: "http://127.0.0.1:4173",
    browserName: "chromium",
    viewport: { width: 1280, height: 720 },
    deviceScaleFactor: 1,
    colorScheme: "light",
    locale: "zh-CN",
    timezoneId: "Asia/Shanghai",
    screenshot: "only-on-failure",
    trace: "retain-on-failure",
  },
  webServer: {
    command: "npm run build && npm run preview -- --host 127.0.0.1 --port 4173",
    url: "http://127.0.0.1:4173",
    reuseExistingServer: !process.env.CI,
  },
  expect: {
    toHaveScreenshot: {
      animations: "disabled",
      caret: "hide",
      scale: "css",
    },
  },
  projects: [
    {
      name: "chromium",
      use: { ...devices["Desktop Chrome"] },
    },
  ],
});

测试文件:

// tests/home.visual.spec.ts
import { test, expect } from "@playwright/test";

test("首页视觉基线", async ({ page }) => {
  await page.goto("/");

  await expect(page).toHaveTitle(/视觉回归/);

  await page.evaluate(async () => {
    await document.fonts.ready;
  });

  await expect(page.locator("main")).toHaveScreenshot("home-main.png", {
    animations: "disabled",
    maxDiffPixels: 20,
    threshold: 0.1,
  });
});

第一次运行时,Playwright 会生成基线,例如:

npx playwright test tests/home.visual.spec.ts --update-snapshots

之后正常运行:

npx playwright test tests/home.visual.spec.ts

如果没有变化,测试应通过。如果存在超出配置阈值的变化,Playwright 会失败,并通常在测试结果目录中保存 actual、expected 和 diff 图片。

这里有几个重要的前置条件:

  • npm run buildnpm run preview 必须能启动页面;
  • 测试环境必须安装相同字体;
  • 浏览器版本应固定或受控;
  • 第一次生成的基线必须经过人工审阅;
  • --update-snapshots 不能作为普通修复命令无条件执行。

三、截图时机:页面“加载完成”不等于页面“稳定”

视觉测试最常见的错误之一,是把 page.goto() 返回当作“可以截图”。

页面可能仍处于以下状态:

  • React 正在 hydration;
  • useEffect 正在请求数据;
  • 图片尚未完成解码;
  • Web Font 尚未加载;
  • CSS transition 或 JavaScript animation 正在运行;
  • 服务端返回的数据带有时间、随机数或用户相关内容。

1. React 渲染、服务端渲染和 hydration

如果页面使用 SSR,服务器先输出 HTML,浏览器再加载 JavaScript 并由 React 接管。这两个阶段可能产生短暂差异。

例如:

export function Clock() {
  return <time>{new Date().toLocaleTimeString()}</time>;
}

服务端和客户端执行时间不同,可能导致:

  • hydration warning;
  • 初始文字不同;
  • 截图基线不稳定。

这不是视觉阈值可以解决的问题。正确做法是让初始输出确定,或明确把依赖浏览器环境的内容放到客户端稳定阶段:

import { useEffect, useState } from "react";

export function Clock() {
  const [time, setTime] = useState<string | null>(null);

  useEffect(() => {
    setTime(new Date(0).toISOString());
  }, []);

  return <time>{time ?? "加载中"}</time>;
}

这个例子使用固定值只是为了说明确定性;生产代码应使用业务上稳定的数据源。关键点是:服务端初始 HTML 和客户端第一次渲染应当具有兼容结构,不能依赖截图测试掩盖 hydration 问题。

在使用 React Server Components 的框架中,服务端组件负责生成服务端结果,客户端组件负责浏览器中的交互和后续更新。截图发生在浏览器端,因此最终图像仍可能受到客户端 effect、数据请求和动画影响。服务端组件并不会自动让截图确定。

2. 等待具体条件,而不是盲目等待时间

不推荐:

await page.waitForTimeout(1000);

等待固定时间只能降低偶发失败概率,不能证明页面已经达到目标状态。机器变慢时,一秒可能不够;机器变快时,测试又浪费时间。

更好的方式是等待用户可观察的条件:

await page.goto("/dashboard");

await expect(page.getByRole("heading", { name: "销售概览" }))
  .toBeVisible();

await expect(page.getByTestId("loading"))
  .toBeHidden();

await expect(page.getByTestId("chart"))
  .toHaveAttribute("data-rendered", "true");

如果组件确实需要异步数据,可以让页面提供明确的状态标记:

export function Chart({ ready }: { ready: boolean }) {
  return (
    <section data-testid="chart" data-rendered={ready ? "true" : "false"}>
      {ready ? <svg aria-label="销售趋势" /> : <p>加载中</p>}
    </section>
  );
}

测试等待 data-rendered="true",比等待任意的 500 毫秒更接近真实状态。


四、字体:视觉回归中最容易被低估的输入

字体不是装饰细节,而是布局计算的一部分。字体改变后,以下量都可能变化:

  • 每个字形的宽度;
  • 文本换行位置;
  • 行盒高度;
  • 字体粗细和抗锯齿边缘;
  • 按钮、卡片和表格的整体尺寸;
  • 文本垂直对齐位置。

1. Fallback 字体为什么会产生大范围差异

考虑一个宽度为 300 像素的标题容器:

.title {
  width: 300px;
  font-family: "Inter", Arial, sans-serif;
  font-size: 32px;
  line-height: 1.2;
}

如果 "Inter" 还没有加载,浏览器可能先使用 Arial。假设标题在 Inter 中的计算宽度为 288 像素,而在 Arial 中为 316 像素:

  1. 使用 Inter 时,标题一行显示;
  2. 使用 Arial 时,文字超过 300 像素;
  3. 标题换成两行;
  4. 后续按钮和卡片整体下移;
  5. 差异从文字边缘扩散到整个页面。

因此,视觉 diff 可能显示几十万像素不同,但真正的根因只是字体尚未就绪。

2. 等待字体加载

浏览器提供了 document.fonts 接口,可用于等待当前文档字体加载过程:

await page.evaluate(async () => {
  await document.fonts.ready;
});

这表示字体加载检查已完成,但它不保证页面中任意指定字体文件一定存在。更严格的测试可以检查具体字体:

await page.evaluate(async () => {
  const loaded = await document.fonts.load(
    '400 16px "Inter"',
    "视觉回归测试"
  );

  if (loaded.length === 0) {
    throw new Error('字体 Inter 未成功加载');
  }
});

前提是 CSS 中确实定义了对应的 @font-face,并且测试服务器能返回字体文件:

@font-face {
  font-family: "Inter";
  src: url("/fonts/inter-latin.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

3. font-display 与测试稳定性的关系

font-display: swap 允许浏览器先显示后备字体,字体就绪后再替换。它通常改善真实用户的首屏感受,但会引入字体替换过程。

font-display: block 可能在短时间内隐藏文字,直到字体就绪或超时。

这两种策略都可以是合理的产品选择,但视觉测试必须明确截图的是:

  • 后备字体状态;
  • 自定义字体状态;
  • 还是产品最终希望用户看到的状态。

通常应等待自定义字体完成后截图,因为那是稳定的最终布局。测试不能只依赖 networkidle:字体可能来自缓存、Service Worker,或者网络请求已经结束但字体尚未完成布局应用。

4. 字体环境必须固定

即使 CSS 指定了相同的字体,以下情况仍可能造成差异:

  • Linux 和 macOS 的系统字体不同;
  • 字体文件版本不同;
  • 浏览器使用不同的字体栅格化实现;
  • 字体缺少中文字符,部分文字回退到另一字体;
  • 同名字体在不同机器上实际指向不同文件。

因此,团队通常选择固定的 CI 容器和浏览器镜像,并把项目使用的 Web Font 纳入构建产物。若必须在多个操作系统上测试,就应为每个环境建立独立基线,而不是让一张基线跨平台复用。


五、动画:同一个页面可以产生无限多张“正确截图”

动画使截图函数显式依赖时间:

I=R(C,V,D,E,T)I = R(C, V, D, E, T)

TT 改变时,即使代码完全不变,截图也可能不同。

例如,一个元素从 translateX(0) 移动到 translateX(100px),动画持续 500 毫秒:

.toast {
  animation: slide-in 500ms ease-out;
}

@keyframes slide-in {
  from {
    transform: translateX(100px);
  }

  to {
    transform: translateX(0);
  }
}

如果一次截图发生在 100 毫秒,元素可能还在右侧;另一次发生在 400 毫秒,元素已接近目标位置。两张图都可能是运行正确的页面,但无法直接作为稳定基线比较。

1. 优先在测试环境关闭动画

可以通过页面级样式禁用 CSS 动画和过渡:

await page.addStyleTag({
  content: `
    *,
    *::before,
    *::after {
      animation-delay: 0s !important;
      animation-duration: 0s !important;
      animation-iteration-count: 1 !important;
      transition-delay: 0s !important;
      transition-duration: 0s !important;
      scroll-behavior: auto !important;
      caret-color: transparent !important;
    }
  `,
});

Playwright 的截图断言还可以使用:

await expect(page).toHaveScreenshot("home-main.png", {
  animations: "disabled",
});

这里应区分两层机制:

  • animations: "disabled" 是测试工具在截图过程中处理动画的能力;
  • page.addStyleTag 是主动修改页面样式的测试控制手段。

如果动画由 requestAnimationFrame、Canvas 或第三方图表库驱动,CSS 规则未必足够。此时应在测试环境注入固定数据,或提供组件级的静态模式。

例如:

type ChartProps = {
  animation?: boolean;
};

export function Chart({ animation = true }: ChartProps) {
  return (
    <div data-animation={animation ? "on" : "off"}>
      {/* 图表实现 */}
    </div>
  );
}

测试环境中传入 animation={false},比在截图后调整阈值更可靠。

2. prefers-reduced-motion 不是自动冻结机制

可以在 Playwright 中设置:

use: {
  reducedMotion: "reduce",
}

并在 CSS 中响应:

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.001ms !important;
    transition-duration: 0.001ms !important;
  }
}

但这只是页面约定。如果组件没有实现对应媒体查询,浏览器设置不会自动阻止所有 JavaScript 动画。测试仍应检查页面实际是否达到稳定状态。

3. 动画截图也可以是有意测试的对象

关闭动画适合验证静态布局,但不能覆盖动画本身。例如,进入动画是否从正确方向出现,属于另一类测试:

  • 使用固定时间控制;
  • 使用 fake timers 或动画库提供的测试时钟;
  • 分别截取初始状态和最终状态;
  • 不要让普通静态视觉基线随机落在动画中间帧。

静态页面基线和动画行为测试应分开,否则一个测试既承担布局验证,又承担时序验证,失败时难以判断原因。


六、像素差异和阈值:允许多少变化才算失败

视觉比较不是简单的“完全相等”或“完全不相等”。浏览器的抗锯齿、阴影边缘和亚像素定位都可能产生极少量差异,因此工具通常提供阈值。

1. 像素差异的形式化定义

设图片共有 N=W×HN = W \times H 个像素。对于第 ii 个像素,基线颜色为:

Bi=(Rb,Gb,Bb,Ab)B_i = (R_b, G_b, B_b, A_b)

实际颜色为:

Ai=(Ra,Ga,Ba,Aa)A_i = (R_a, G_a, B_a, A_a)

可以定义该像素的颜色距离,例如使用各通道最大差异:

di=max(RaRb,GaGb,BaBb)d_i = \max( |R_a - R_b|, |G_a - G_b|, |B_a - B_b| )

给定颜色阈值 tt,若:

di>td_i > t

则把该像素视为差异像素。设差异像素数量为 MM,差异比例为:

p=MNp = \frac{M}{N}

这两个量分别回答不同问题:

  • MM:总共有多少像素变化;
  • pp:变化占整个截图的比例。

例如,一张 100 × 50 的图片共有:

N=100×50=5000N = 100 \times 50 = 5000

如果有 8 个像素超过颜色阈值:

M=8,p=85000=0.0016=0.16%M = 8,\quad p = \frac{8}{5000}=0.0016=0.16\%

maxDiffPixels: 8maxDiffPixelRatio: 0.0016 在这个例子中表达了相同的容忍规模,但在图片尺寸变化时行为不同。

2. Playwright 中三种常见阈值

Playwright 的截图断言常用这些配置:

await expect(page).toHaveScreenshot("dashboard.png", {
  threshold: 0.1,
  maxDiffPixels: 100,
  maxDiffPixelRatio: 0.001,
});

其含义应分别理解:

  • threshold:像素颜色比较的容差,通常以每个颜色通道的归一化差异表示;
  • maxDiffPixels:允许超过颜色阈值的差异像素绝对数量;
  • maxDiffPixelRatio:允许的差异像素占截图总像素的比例。

threshold 不是“允许 10% 的图片不同”。它解决的是“某一个像素颜色差多少才算不同”,而 maxDiffPixelsmaxDiffPixelRatio 解决的是“允许多少个像素被判定为不同”。

3. 阈值的完整算例

假设截图大小为 1280 × 720

N=1280×720=921600N = 1280 \times 720 = 921600

配置:

{
  threshold: 0.1,
  maxDiffPixels: 200,
  maxDiffPixelRatio: 0.0005
}

比例上限对应:

921600×0.0005=460.8921600 \times 0.0005 = 460.8

因此,如果工具同时要求不超过绝对数量和比例,实际允许值通常受更严格的一项约束,即不超过 200 个差异像素。

现在考虑三种结果:

情况 A:阴影边缘产生 35 个轻微差异像素

如果每个像素的颜色差都不超过 threshold,它们不会计入 MM。测试通过。

情况 B:按钮颜色发生变化,产生 180 个差异像素

若这些像素都超过颜色阈值:

M=180M = 180

由于:

180200180 \leq 200

且:

180/9216000.000195<0.0005180 / 921600 \approx 0.000195 < 0.0005

测试通过。但这未必是业务上可接受的结果,因为 180 个像素可能恰好覆盖一个重要图标。

情况 C:标题换行,产生 1200 个差异像素

M=1200M = 1200

超过 200,也超过比例上限,测试失败。此时不应简单调大阈值,而应检查字体、容器宽度、文案或 CSS 布局。

4. 阈值不是修复不确定性的工具

以下做法通常是错误的:

await expect(page).toHaveScreenshot("home.png", {
  maxDiffPixels: 50000,
  threshold: 0.5,
});

如果失败原因是字体未加载、动画未停止或数据随机化,增大阈值只会隐藏真实问题。

更合理的推导顺序是:

  1. 先固定浏览器、视口、字体和数据;
  2. 再关闭或控制动画;
  3. 再等待图片、字体和业务状态;
  4. 观察稳定运行下自然产生的差异;
  5. 最后针对已知的渲染噪声设置小范围阈值。

阈值应表达已知的技术噪声,而不是表达“这次差异先放过”。

5. 固定尺寸优先于依赖比例

如果页面截图尺寸固定,maxDiffPixels 更容易解释:允许最多多少个像素变化。

如果同一测试覆盖多个分辨率,maxDiffPixelRatio 更适合表达比例,但它可能放过较大的绝对变化。例如:

  • 0.1%1280 × 720 是约 922 个像素;
  • 0.1%3840 × 2160 是约 8294 个像素。

因此,响应式布局通常应为不同视口建立独立基线,而不是让一个比例阈值覆盖所有设备。


七、截图范围:整页、组件和状态必须明确

1. 整页截图适合验证布局关系

await expect(page).toHaveScreenshot("home-full-page.png", {
  fullPage: true,
});

整页截图能发现:

  • 页面顶部导航高度变化;
  • 多个区块之间的间距变化;
  • 页面高度异常;
  • 页脚和内容是否重叠。

但整页截图也容易受到动态内容、懒加载和滚动位置影响。一个无限滚动列表不适合直接作为整页基线。

2. 组件截图适合缩小故障范围

const card = page.getByTestId("summary-card");

await expect(card).toHaveScreenshot("summary-card.png", {
  animations: "disabled",
});

组件截图将比较范围限制在卡片边界内,差异更容易定位。不过,组件在真实页面中的布局关系可能被遗漏。例如,卡片单独正确,不代表它与相邻卡片之间没有溢出。

因此,两种范围承担不同职责:

  • 组件截图:验证局部视觉契约;
  • 页面截图:验证组合后的空间关系。

3. 状态必须拆开

同一个组件的以下状态不应混成一个基线:

  • 默认状态;
  • hover 状态;
  • focus 状态;
  • disabled 状态;
  • loading 状态;
  • error 状态;
  • 空数据状态;
  • 移动端折叠状态。

例如,验证 hover:

const button = page.getByRole("button", { name: "开始检查" });

await button.hover();

await expect(button).toHaveScreenshot("start-button-hover.png");

验证键盘 focus:

await page.keyboard.press("Tab");

await expect(page.getByRole("button", { name: "开始检查" }))
  .toHaveScreenshot("start-button-focus.png");

这里的 focus 状态必须通过真实交互或明确的状态控制产生,而不是直接修改 DOM 类名。否则测试可能没有覆盖实际的焦点管理逻辑。


八、数据确定性:React 状态变化会直接改变基线

视觉截图的输入数据必须可重复。以下值会使基线不稳定:

export function OrderSummary() {
  const orderId = Math.random().toString(36);
  const updatedAt = new Date().toLocaleString();

  return (
    <section>
      <p>订单:{orderId}</p>
      <p>更新时间:{updatedAt}</p>
    </section>
  );
}

即使布局完全正确,每次截图中的文字也不同。若这些数据不是测试目标,应在测试环境中固定:

await page.addInitScript(() => {
  const fixedNow = new Date("2025-01-01T08:00:00.000Z");

  class FixedDate extends Date {
    constructor(...args: ConstructorParameters<typeof Date>) {
      if (args.length === 0) {
        super(fixedNow.getTime());
      } else {
        super(...args);
      }
    }

    static now() {
      return fixedNow.getTime();
    }
  }

  // 测试代码中应谨慎使用全局替换,避免影响第三方库。
  Object.defineProperty(globalThis, "Date", {
    configurable: true,
    value: FixedDate,
  });
});

更推荐从数据层解决:

// 测试环境返回固定 fixture
const dashboardData = {
  total: 128,
  change: 0.12,
  updatedAt: "2025-01-01 16:00",
};

如果页面调用后端接口,可以在 Playwright 中拦截:

await page.route("**/api/dashboard", async (route) => {
  await route.fulfill({
    status: 200,
    contentType: "application/json",
    body: JSON.stringify({
      total: 128,
      change: 0.12,
      updatedAt: "2025-01-01 16:00",
    }),
  });
});

请求拦截应发生在 page.goto() 之前,否则首个请求可能已经发出:

await page.route("**/api/dashboard", handler);
await page.goto("/dashboard");

错误状态也应该显式测试,而不是让后端偶尔失败:

await page.route("**/api/dashboard", async (route) => {
  await route.fulfill({
    status: 500,
    contentType: "application/json",
    body: JSON.stringify({ message: "service unavailable" }),
  });
});

await page.goto("/dashboard");

await expect(page.getByRole("alert"))
  .toHaveText("数据加载失败");

这样可以把“错误 UI 的视觉基线”和“正常 UI 的视觉基线”分开。


九、一个稳定的测试流程和状态路径

视觉测试可以抽象成以下流程:

flowchart TD
    A[启动固定浏览器环境] --> B[加载页面并拦截确定性数据]
    B --> C[等待 React 页面状态]
    C --> D[等待字体和图片]
    D --> E[关闭或控制动画]
    E --> F[生成 actual 截图]
    F --> G[与 baseline 比较]
    G --> H{是否超过阈值}
    H -->|否| I[测试通过]
    H -->|是| J[生成 diff 和 trace]
    J --> K[人工审阅变化]
    K --> L{变化是否符合需求}
    L -->|否| M[修复代码或测试环境]
    L -->|是| N[更新并提交基线]

关键路径不是“截图后看图片”,而是:

  1. 固定输入;
  2. 等待状态;
  3. 固定字体;
  4. 冻结时间相关因素;
  5. 执行比较;
  6. 让人判断变化是否符合产品意图。

如果把第 3、4 步省略,阈值就会被迫承担环境不稳定的责任。


十、故障诊断:先判断差异属于哪一类

1. 差异覆盖整页文字边缘

常见表现:

  • 所有文字附近都有细碎色差;
  • 文本宽度略有变化;
  • 行高和换行位置可能不同。

优先检查:

await page.evaluate(async () => {
  console.log({
    status: document.fonts.status,
    inter: document.fonts.check('400 16px "Inter"'),
  });
});

然后检查:

  • 浏览器和操作系统是否一致;
  • 字体文件是否返回 200;
  • CSS 是否拼写正确;
  • 字体粗细是否存在对应文件;
  • 中文是否回退到不同字体。

2. 差异呈现元素移动后的“双影”

例如原位置和新位置都出现颜色块,通常说明:

  • 容器宽度变化;
  • margin、padding 或 gap 改变;
  • 字体换行;
  • 页面滚动位置不同;
  • 元素仍处于 transition 或 transform 中。

此时应先将差异图与 DOM 边界结合查看,而不是立即增加 maxDiffPixels

3. 只有阴影、圆角和文字边缘出现少量差异

这类差异可能来自:

  • 不同浏览器版本;
  • 不同 GPU 或栅格化路径;
  • 设备像素比;
  • 抗锯齿和亚像素取整。

如果差异始终稳定且只出现在这些边缘,可以设置较小的颜色或像素容差。但如果差异位置每次变化,则更可能是环境或时序不稳定。

4. 图片区域完全不同

检查:

  • 图片是否加载失败;
  • 是否使用了随机占位图;
  • 图片是否未完成解码;
  • 服务端返回的图片是否动态;
  • 图片尺寸是否改变。

可以等待图片完成解码:

await page.evaluate(async () => {
  const images = Array.from(document.images);

  await Promise.all(
    images.map((image) => {
      if (image.complete) {
        return image.decode?.() ?? Promise.resolve();
      }

      return new Promise<void>((resolve) => {
        image.addEventListener("load", () => resolve(), { once: true });
        image.addEventListener("error", () => resolve(), { once: true });
      });
    }),
  );
});

这里对 error 直接继续只是为了让测试后续产生清晰的截图或断言;生产测试通常还应另加资源加载失败检查,避免把破图当作稳定状态。


十一、人工审阅:测试失败不等于代码错误

视觉比较只能提供证据,不能决定产品意图。

一次失败可能对应三种情况:

1. 预期变更

例如产品需求要求:

  • 主色从蓝色改成紫色;
  • 标题文案变长;
  • 卡片增加图标;
  • 移动端间距重新设计。

此时差异是正确的。审阅者应确认:

  • 变化是否覆盖在需求范围内;
  • 是否出现了无意的连带变化;
  • 其他状态和视口是否仍然正确;
  • 基线更新是否与代码变更在同一个提交中。

2. 非预期回归

例如只修改按钮颜色,却导致:

  • 字体回退;
  • 页面宽度溢出;
  • 弹窗被遮挡;
  • focus outline 消失;
  • 暗色主题颜色错误。

这种情况应修复代码,而不是更新基线。

3. 测试环境噪声

例如 CI 机器临时加载了不同字体,或浏览器版本被自动升级。这类变化不能批准为产品基线;应先恢复环境可重复性,再重新比较。

建议在 CI 失败时保留:

  • actual;
  • baseline;
  • diff;
  • Playwright trace;
  • 测试日志;
  • 浏览器版本;
  • 字体和操作系统信息。

这样审阅者能区分“组件发生了什么变化”和“测试机器发生了什么变化”。


十二、基线更新的安全流程

不要使用以下流程:

npx playwright test --update-snapshots
git add .
git commit -m "update screenshots"

它会把所有失败测试都更新成新基线,包括真正的回归。

更安全的流程是:

  1. 在本地或隔离 CI 中复现失败;
  2. 查看 actual、baseline 和 diff;
  3. 确认差异对应已批准的需求;
  4. 只更新指定测试;
  5. 重新运行未更新的其他视觉测试;
  6. 在提交中同时包含实现变更、测试变更和基线变更。

例如只更新一个测试:

npx playwright test tests/home.visual.spec.ts --update-snapshots

然后检查版本库中的图片差异,而不是只看命令是否成功:

git status
git diff --stat
git diff -- tests/home.visual.spec.ts

图片本身可能无法通过普通文本 diff 阅读,因此必须打开图像或 CI 的视觉审阅界面。


十三、哪些内容不适合直接用截图基线验证

1. 文本内容和业务规则

截图可以发现文字显示位置变化,但不适合精确验证:

  • 金额是否计算正确;
  • 权限是否正确;
  • 日期格式是否符合规则;
  • 无障碍名称是否完整。

这些应使用文本断言、单元测试或领域测试。视觉测试可以作为最终呈现的补充。

2. 无限列表和动态图表

无限滚动、实时数据、随机图表会持续改变截图输入。可以:

  • 使用固定 fixture;
  • 固定滚动位置和列表窗口;
  • 截取确定的组件区域;
  • 关闭图表动画;
  • 单独测试数据转换逻辑。

3. 跨平台像素完全一致

像素级比较不天然保证跨操作系统一致。不同平台的字体和图形栅格化结果可能不同。实际策略通常是:

  • CI 统一在固定 Linux 容器中运行;
  • 或为不同平台维护独立基线;
  • 不把本地 macOS 截图直接当作 Linux CI 基线。

十四、客户端与服务端边界

React 组件代码可以在服务端执行,也可以在浏览器端执行,但截图一定发生在浏览器渲染完成之后。

需要分别控制:

服务端阶段

  • SSR 输出是否确定;
  • 服务端数据是否固定;
  • 时区、语言和日期是否固定;
  • 服务器生成的随机 ID 是否稳定;
  • 初始 HTML 是否与客户端 hydration 兼容。

客户端阶段

  • React hydration 是否完成;
  • useEffect 是否更新了页面;
  • 浏览器字体和图片是否就绪;
  • 交互状态是否通过真实用户操作产生;
  • 动画、过渡和第三方脚本是否停止。

例如,服务端读取当前时间,客户端又在 effect 中显示当前时间,即使两端各自都“正确”,截图也可能稳定失败。解决方案是让时间由服务端作为确定数据传入,或者在测试中注入固定时间,而不是放宽视觉阈值。

useLayoutEffect 也不能被当作“截图前一定完成”的通用保证。它主要影响浏览器提交后的布局读取和同步更新;如果组件还依赖网络请求、字体或后续 effect,仍然需要等待具体条件。


十五、把视觉测试放进 CI 时的取舍

视觉测试适合在以下边界运行:

  • 固定浏览器版本;
  • 固定容器或操作系统;
  • 固定视口;
  • 固定数据;
  • 固定字体;
  • 固定截图时机。

CI 中可以先执行确定性检查,再执行视觉断言:

npm run typecheck
npm run lint
npm run test
npx playwright test

这样,类型错误和逻辑测试失败不会被视觉差异混在一起。

当视觉测试失败时,恢复顺序应当是:

  1. 确认页面服务成功启动;
  2. 确认浏览器版本和视口没有改变;
  3. 确认字体请求和图片请求没有失败;
  4. 确认页面数据、时间和随机数固定;
  5. 确认动画和滚动位置稳定;
  6. 查看 diff 判断是局部变化还是整体漂移;
  7. 最后才调整合理阈值或更新基线。

十六、一个可执行的检查模板

可以把页面稳定化过程封装成辅助函数:

// tests/support/stabilize-page.ts
import type { Page } from "@playwright/test";

export async function stabilizePage(page: Page) {
  await page.addStyleTag({
    content: `
      *,
      *::before,
      *::after {
        animation: none !important;
        transition: none !important;
        caret-color: transparent !important;
        scroll-behavior: auto !important;
      }
    `,
  });

  await page.evaluate(async () => {
    await document.fonts.ready;

    const images = Array.from(document.images);
    await Promise.all(
      images.map(async (image) => {
        if (!image.complete) {
          await new Promise<void>((resolve) => {
            image.addEventListener("load", () => resolve(), { once: true });
            image.addEventListener("error", () => resolve(), { once: true });
          });
        }

        if (typeof image.decode === "function") {
          try {
            await image.decode();
          } catch {
            // 破损图片由独立断言负责报告。
          }
        }
      }),
    );
  });
}

使用:

import { test, expect } from "@playwright/test";
import { stabilizePage } from "./support/stabilize-page";

test("仪表盘稳定状态", async ({ page }) => {
  await page.route("**/api/dashboard", async (route) => {
    await route.fulfill({
      status: 200,
      contentType: "application/json",
      body: JSON.stringify({
        total: 128,
        change: 0.12,
        updatedAt: "2025-01-01 16:00",
      }),
    });
  });

  await page.goto("/dashboard");

  await expect(page.getByRole("heading", { name: "销售概览" }))
    .toBeVisible();

  await stabilizePage(page);

  await expect(page).toHaveScreenshot("dashboard.png", {
    fullPage: true,
    animations: "disabled",
    maxDiffPixels: 100,
    threshold: 0.1,
  });
});

这个辅助函数并不能替代业务状态断言。它只负责处理字体、图片、动画等通用稳定因素;页面是否真的完成了数据渲染,仍应由 toBeVisible、文本断言或明确的状态标记来证明。


十七、最终判断标准

一套可信的 React 视觉回归测试应满足以下因果链:

固定环境固定数据等待稳定状态控制字体和动画比较截图人工审阅差异\text{固定环境} \rightarrow \text{固定数据} \rightarrow \text{等待稳定状态} \rightarrow \text{控制字体和动画} \rightarrow \text{比较截图} \rightarrow \text{人工审阅差异}

其中任何一环不稳定,最终的像素差异就难以解释。

  • 截图基线定义了被批准的具体视觉结果;
  • 字体决定文本尺寸、换行和布局;
  • 动画使截图依赖时间,必须冻结或显式控制;
  • 阈值只应容忍已知的像素噪声,不能掩盖不确定性;
  • 审阅决定差异是预期设计、真实回归还是测试环境问题。

React 19 组件最终仍由浏览器完成布局、字体加载、动画执行和像素栅格化。视觉回归的核心不是“多截几张图”,而是把这些外部变量变成可控制、可解释、可审阅的测试输入。


系列导航与关联阅读

官方资料

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