React 基础体系 · 第 52/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React 视觉回归:截图基线、字体、动画、阈值和审阅
视觉回归测试用于回答一个具体问题:在输入、浏览器环境和页面状态相同的前提下,本次代码变更是否改变了用户看到的像素结果。
它与单元测试、组件测试的关注点不同:
- 单元测试验证函数或状态转换是否符合预期;
- DOM 测试验证元素、属性和文本是否存在;
- 视觉回归测试验证最终渲染结果是否发生了可接受范围之外的变化。
React 本身没有“截图基线”或“视觉差异阈值”的 API。React 负责生成和更新 UI,截图通常由 Playwright、Cypress 等浏览器自动化工具完成,图像比较则由测试工具或专用服务完成。因此,视觉回归的结果不仅受 React 代码影响,也受浏览器版本、操作系统、字体、动画时刻、网络数据和设备像素比影响。
一、先定义视觉回归中的几个核心对象
1. 被测页面不是一个纯函数
可以把某个页面的截图抽象为:
其中:
- :最终截图;
- :浏览器渲染过程;
- :组件代码和 CSS;
- :视口尺寸、设备像素比、颜色方案等视觉环境;
- :页面数据和用户状态;
- :浏览器、操作系统、字体和渲染引擎;
- :截图发生的时刻。
视觉回归想比较的是两个图像:
和
理想情况下,除了代码版本外,其余变量都应该保持不变。如果字体从 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. 差异图是什么
视觉测试一般会生成三类结果:
- 实际图(actual):当前代码产生的截图;
- 基线图(expected/baseline):已批准的截图;
- 差异图(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 build和npm 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 像素:
- 使用 Inter 时,标题一行显示;
- 使用 Arial 时,文字超过 300 像素;
- 标题换成两行;
- 后续按钮和卡片整体下移;
- 差异从文字边缘扩散到整个页面。
因此,视觉 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 纳入构建产物。若必须在多个操作系统上测试,就应为每个环境建立独立基线,而不是让一张基线跨平台复用。
五、动画:同一个页面可以产生无限多张“正确截图”
动画使截图函数显式依赖时间:
当 改变时,即使代码完全不变,截图也可能不同。
例如,一个元素从 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. 像素差异的形式化定义
设图片共有 个像素。对于第 个像素,基线颜色为:
实际颜色为:
可以定义该像素的颜色距离,例如使用各通道最大差异:
给定颜色阈值 ,若:
则把该像素视为差异像素。设差异像素数量为 ,差异比例为:
这两个量分别回答不同问题:
- :总共有多少像素变化;
- :变化占整个截图的比例。
例如,一张 100 × 50 的图片共有:
如果有 8 个像素超过颜色阈值:
maxDiffPixels: 8 和 maxDiffPixelRatio: 0.0016 在这个例子中表达了相同的容忍规模,但在图片尺寸变化时行为不同。
2. Playwright 中三种常见阈值
Playwright 的截图断言常用这些配置:
await expect(page).toHaveScreenshot("dashboard.png", {
threshold: 0.1,
maxDiffPixels: 100,
maxDiffPixelRatio: 0.001,
});
其含义应分别理解:
threshold:像素颜色比较的容差,通常以每个颜色通道的归一化差异表示;maxDiffPixels:允许超过颜色阈值的差异像素绝对数量;maxDiffPixelRatio:允许的差异像素占截图总像素的比例。
threshold 不是“允许 10% 的图片不同”。它解决的是“某一个像素颜色差多少才算不同”,而 maxDiffPixels 和 maxDiffPixelRatio 解决的是“允许多少个像素被判定为不同”。
3. 阈值的完整算例
假设截图大小为 1280 × 720:
配置:
{
threshold: 0.1,
maxDiffPixels: 200,
maxDiffPixelRatio: 0.0005
}
比例上限对应:
因此,如果工具同时要求不超过绝对数量和比例,实际允许值通常受更严格的一项约束,即不超过 200 个差异像素。
现在考虑三种结果:
情况 A:阴影边缘产生 35 个轻微差异像素
如果每个像素的颜色差都不超过 threshold,它们不会计入 。测试通过。
情况 B:按钮颜色发生变化,产生 180 个差异像素
若这些像素都超过颜色阈值:
由于:
且:
测试通过。但这未必是业务上可接受的结果,因为 180 个像素可能恰好覆盖一个重要图标。
情况 C:标题换行,产生 1200 个差异像素
超过 200,也超过比例上限,测试失败。此时不应简单调大阈值,而应检查字体、容器宽度、文案或 CSS 布局。
4. 阈值不是修复不确定性的工具
以下做法通常是错误的:
await expect(page).toHaveScreenshot("home.png", {
maxDiffPixels: 50000,
threshold: 0.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[更新并提交基线]
关键路径不是“截图后看图片”,而是:
- 固定输入;
- 等待状态;
- 固定字体;
- 冻结时间相关因素;
- 执行比较;
- 让人判断变化是否符合产品意图。
如果把第 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"
它会把所有失败测试都更新成新基线,包括真正的回归。
更安全的流程是:
- 在本地或隔离 CI 中复现失败;
- 查看 actual、baseline 和 diff;
- 确认差异对应已批准的需求;
- 只更新指定测试;
- 重新运行未更新的其他视觉测试;
- 在提交中同时包含实现变更、测试变更和基线变更。
例如只更新一个测试:
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
这样,类型错误和逻辑测试失败不会被视觉差异混在一起。
当视觉测试失败时,恢复顺序应当是:
- 确认页面服务成功启动;
- 确认浏览器版本和视口没有改变;
- 确认字体请求和图片请求没有失败;
- 确认页面数据、时间和随机数固定;
- 确认动画和滚动位置稳定;
- 查看 diff 判断是局部变化还是整体漂移;
- 最后才调整合理阈值或更新基线。
十六、一个可执行的检查模板
可以把页面稳定化过程封装成辅助函数:
// 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 视觉回归测试应满足以下因果链:
其中任何一环不稳定,最终的像素差异就难以解释。
- 截图基线定义了被批准的具体视觉结果;
- 字体决定文本尺寸、换行和布局;
- 动画使截图依赖时间,必须冻结或显式控制;
- 阈值只应容忍已知的像素噪声,不能掩盖不确定性;
- 审阅决定差异是预期设计、真实回归还是测试环境问题。
React 19 组件最终仍由浏览器完成布局、字体加载、动画执行和像素栅格化。视觉回归的核心不是“多截几张图”,而是把这些外部变量变成可控制、可解释、可审阅的测试输入。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React 端到端测试:Playwright、登录态、网络、并行和失败证据
- 下一篇:React Storybook:Story、交互测试、文档、主题和评审
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论