各位同学,大家好,欢迎来到今天的讲座。我是你们的“像素守护者”,也是那个每次看到 UI 差了一像素就会浑身发抖的老司机。
今天我们不聊那些虚头巴脑的架构图,也不聊怎么用 TypeScript 写得像瑞士钟表一样精准。今天,我们来聊一个让前端开发人员闻风丧胆,却又不得不爱的主题——视觉回归测试,简称 VRT。
想象一下,你辛辛苦苦写了一天代码,改了一个 padding,把 10px 改成了 12px,然后提交了代码。第二天,CI(持续集成)管道响了,报告说:“嘿,你的按钮变大了,测试失败。”
你心想:“我靠,我明明只是想让它舒服点,怎么就炸了?”然后你打开 Diff 图,发现它确实大了 2px。你觉得自己是受害者,但实际上,你是被你的眼睛出卖了。因为人类的眼睛对 2px 的差异并不敏感,但机器的眼睛——或者说机器的像素比较算法——是冷血的。
所以,为了拯救我们脆弱的神经,也为了防止产品经理指着屏幕说“这不对劲”的时候你一脸懵逼,我们需要在 CI 流程里集成视觉回归测试。
准备好了吗?让我们走进这个充满了截图、像素点和“这玩意儿到底是怎么跑的”的世界。
第一章:为什么我们需要 VRT?(或者说,为什么你的眼睛是瞎的)
在讲技术之前,我们得先聊聊“痛点”。你们有没有经历过这种场景:
场景一:CSS 的混沌魔法
你改了一个全局的 CSS 变量 --button-bg: #ccc,结果你发现,不仅按钮变色了,整个页面的面包屑导航、甚至那个你以为跟它八竿子打不着的“联系我们”链接都变了。为什么?因为 CSS 的级联效应就像俄罗斯套娃,你动了一个,全家都得跟着抖。
场景二:布局的“幽灵”
你改了一行逻辑代码,比如把 map 的顺序改了,结果你的列表项在 DOM 里的顺序变了。虽然功能一样,但视觉上,原本在左边的“张三”跑到右边去了。你的眼睛看不出来,但 Percy 看得出来。
场景三:CI 环境的“幻觉”
你在本地开发环境,字体加载了,图片加载了,连背景图都清晰可见。但在 CI 环境(比如 GitHub Actions 的 Linux 服务器),字体可能没加载,图片可能是个占位符,甚至有时候连 z-index 都因为浏览器的渲染模式不同而变了。
这时候,视觉回归测试就是你的保镖。它的工作很简单:拍一张照,保存起来,下次再拍一张,如果两张照片不一样,就报警。
第二章:工具箱里有什么?(别买错了锤子)
在 React 生态里,做视觉测试,我们通常不是自己写一个图像处理算法(除非你想发一篇顶会论文),而是用现成的工具。主要有这么几把锤子:
- Percy (现在属于 GitLab): 以前是独立的大佬,现在跟 GitLab 联姻了。它的界面非常漂亮,像是在看艺术展。
- Chromatic: Storybook 官方推荐,也是目前最流行的。它跟 Storybook 的集成简直是“天作之合”,甚至可以说“这俩是亲生的”。
- Puppeteer + Pixelmatch: 自己动手,丰衣足食。适合不想花钱,或者对隐私有极高要求,且愿意花时间调教工具的大佬。
今天,为了照顾大多数人的钱包和智商,我们将重点讲解 Chromatic 的集成方式,因为它基本上是开箱即用的。当然,为了显得我是个专家,我们也会顺带提一下 Puppeteer 的实现思路。
第三章:Storybook,你的 UI 单元测试的“家”
视觉回归测试,最核心的前提是:隔离。
你不能测试一个包含所有路由的 App,那样太乱了。你需要测试一个个独立的组件。而 Storybook 就是这个组件的展示厅。
如果你没有 Storybook,恭喜你,你现在可以去隔壁的厕所哭一会儿了。Storybook 是 React 开发者的“上帝模式”,它把组件从业务逻辑中剥离出来,让你能独立地看、独立地测。
第一步:安装 Storybook
如果你的项目里还没有 Storybook,别慌,敲一行命令:
npx storybook@latest init
这玩意儿会自动检测你的 React 框架,帮你配置好 Webpack/Vite,甚至还会帮你写几个 Story 文件作为示例。
第二步:写一个 Story
让我们来写一个简单的按钮组件 Button.tsx,然后给它写个 Story。
// Button.tsx
import React from 'react';
interface ButtonProps {
children: React.ReactNode;
variant?: 'primary' | 'secondary';
}
export const Button: React.FC<ButtonProps> = ({ children, variant = 'primary' }) => {
const styles = {
primary: {
backgroundColor: '#007bff',
color: 'white',
padding: '10px 20px',
},
secondary: {
backgroundColor: '#6c757d',
color: 'white',
padding: '10px 20px',
},
};
return (
<button style={styles[variant]}>
{children}
</button>
);
};
现在,我们在 Button.stories.tsx 里定义一下这个组件的不同状态:
import { Button } from './Button';
// 这里的 'primary' 和 'secondary' 就是 Story 的名字
export const Primary = () => <Button variant="primary">Click Me</Button>;
export const Secondary = () => <Button variant="secondary">Cancel</Button>;
// 设置默认导出,这样 Storybook 启动时能看到这个
export default {
title: 'Atoms/Button',
component: Button,
};
看到没?就这么简单。Storybook 会自动读取这个文件,然后生成一个漂亮的 UI 面板,你可以在这里切换 Primary 和 Secondary。
第四章:Chromatic 集成实战(让 CI 变得“有眼有珠”)
好了,现在我们有了一个展示厅(Storybook)。接下来,我们要把 CI 引进来。我们的目标是:每次有人提交代码,CI 就自动打开 Storybook,拍下每一张 Story 的照片,上传到云端,和上次的照片对比,如果有变化,就报错。
4.1 安装 Chromatic
在项目根目录下运行:
npx chromatic --project-token YOUR_TOKEN_HERE
这会自动安装 @storybook/chromatic 依赖,并且配置好 package.json 里的脚本。它会检测到你的 Storybook,然后问你要不要集成。选 Yes,它就会帮你写好配置文件 .chromaticrc.js。
4.2 配置文件
.chromaticrc.js 是 Chromatic 的配置文件。虽然默认配置能用,但为了显摆技术,我们来看看怎么配置。
// .chromaticrc.js
module.exports = {
// 项目的名称,方便在 Dashboard 里区分
project: 'my-awesome-react-app',
// 比较阈值,0.01 表示只有 1% 的像素不同才算失败
// 默认是 0.01,有些 UI 变化可能只有 0.005%,这时候可以调大一点
diffThreshold: 0.01,
// 忽略某些截图。比如背景图变了,但你只想看按钮,就可以用这个
ignoreFiles: [
'**/node_modules/**',
'**/dist/**',
'**/build/**',
],
// 启用自动化更新。如果你刚把项目接入 Chromatic,可以先选 true,
// 等第一波截图跑完,确认没问题了,再改成 false,强迫自己手动检查。
updateSnapshot: process.env.CHROMATIC_UPDATE_SNAPSHOT === 'true',
};
4.3 CI 脚本配置
这是最关键的一步。我们需要告诉 CI 系统:“嘿,别光跑 Jest 了,跑一下 Chromatic。”
我们以 GitHub Actions 为例。在你的仓库根目录创建 .github/workflows/ci.yml。
name: CI with Visual Tests
on: [push, pull_request]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
# 1. 检出代码
- uses: actions/checkout@v3
# 2. 设置 Node.js 环境
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: '18'
# 3. 安装依赖(这一步很重要,必须和本地环境一致)
- name: Install Dependencies
run: npm ci
# 4. 构建 Storybook(Chromatic 需要编译后的代码)
- name: Build Storybook
run: npm run build-storybook
# 5. 运行 Chromatic 测试
- name: Deploy to Chromatic
# 这里的 ${{ secrets.CHROMATIC_PROJECT_TOKEN }} 是你在 Chromatic 官网生成的 Token
# 去你的仓库设置里添加这个 Secret
run: npx chromatic --project-token ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
这里有个坑,我得提醒你们:
注意看 npm run build-storybook。如果你的 Storybook 构建时间很长(比如依赖很多组件库),在 CI 里跑这个会非常慢。Chromatic 其实支持“增量构建”,它不需要每次都重新构建整个 Storybook,只需要构建变更的部分。但是,默认的集成脚本可能不会用这个。如果你遇到了 CI 超时的问题,那是正常的,因为 Storybook 真的很重。
第五章:处理“假阳性”——CI 环境的诡异现象
当你把代码推上去,过了一会儿,CI 报错了。你打开 Diff 图,看着那个红红绿绿的差异,心里想:“这特么明明一样啊!”
别急,这是常态。CI 环境和你的本地环境不一样,这种差异我们称之为“CI 幻觉”。
5.1 字体加载问题
这是最常见的问题。CI 服务器上没有安装所有系统字体,或者加载字体的时间不一样。
解决方法: 在 Storybook 里配置字体加载。Chromatic 其实内置了处理,但有时候你需要确保你的 Storybook 环境等待字体加载完成再截图。
5.2 图片加载
本地可能加载了本地图片,CI 里可能加载了网络图片,或者反过来。网络图片有时候加载慢,有时候加载失败。
解决方法: 尽量在 Storybook 里使用占位符或者 SVG 图片,避免依赖外部网络资源。如果必须用,要设置好超时时间。
5.3 背景颜色
有时候你改了 CSS,把 body 的背景色改了,结果整个页面都变了。
解决方法: 在 Storybook 的 preview.tsx 里设置固定的背景色。
// .storybook/preview.tsx
import type { Preview } from '@storybook/react';
import '../src/index.css'; // 引入你的全局样式
const preview: Preview = {
parameters: {
// 设置全局背景色,防止 CI 环境差异
backgrounds: {
default: 'white',
values: [
{ name: 'white', value: '#ffffff' },
{ name: 'gray', value: '#f0f0f0' },
],
},
},
};
export default preview;
5.4 动画和定时器
你在组件里用了 setTimeout 或者 CSS 动画。本地环境可能跑得快,CI 环境跑得慢。
解决方法: 使用 Storybook 的 play 函数来等待元素出现。
// Button.stories.tsx
import { Button } from './Button';
import { expect, userEvent } from '@storybook/test';
export const Loading = () => <Button>Loading...</Button>;
Loading.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button', { name: 'Loading' });
// 等待按钮文字变化(模拟异步操作)
await expect(button).toHaveTextContent('Loading...');
await new Promise(r => setTimeout(r, 1000)); // 模拟 1秒 后加载完成
await expect(button).toHaveTextContent('Done!');
};
第六章:高级策略——如何优雅地管理测试
如果你把所有组件都跑一遍视觉测试,那 CI 就会变成一个无底洞,每次都要跑 2 小时。我们需要策略。
6.1 按需测试
不要测试所有 Story。只测试那些你最近修改过的 Story。Chromatic 提供了一个很好的功能:Diff Coverage。它会自动分析 Git 提交记录,只对变更的组件进行截图。
6.2 忽略特定区域
有时候,你只想看按钮有没有变形,不想管按钮旁边的文字有没有变。你可以使用 ignore 属性。
import { Meta, StoryObj } from '@storybook/react';
const meta = {
title: 'Components/Modal',
component: Modal,
} satisfies Meta<typeof Modal>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
parameters: {
// 告诉 Chromatic,忽略 Modal 里的 'footer' 区域
chromatic: {
ignore: ['.modal-footer'],
},
},
};
6.3 自定义对比阈值
如果你的 UI 变化非常细微(比如边框颜色微调),默认的 0.01% 阈值可能会让你抓狂。你可以针对特定的 Story 调高阈值。
export const SubtleChange: Story = {
parameters: {
chromatic: {
diffThreshold: 0.1, // 放宽到 0.1%
},
},
};
第七章:代码示例——一个完整的 CI 流程演示
为了让你彻底明白,我们来模拟一个真实的场景。
假设我们有一个登录组件 LoginForm.tsx,它依赖了 Button 和 Input。
- 提交代码:你改了
Button的圆角,从4px改成了8px。 - 触发 CI:GitHub Actions 开始运行。
- 执行步骤:
npm ci:安装依赖。npm run build-storybook:构建 Storybook。这时候,Storybook 会编译所有的组件,包括Button。npx chromatic:Chromatic 启动一个无头浏览器(Headless Browser),打开 Storybook。
- 截图与上传:
- Chromatic 扫描
Button.stories.tsx。 - 它发现了
Primary和Secondary两个 Story。 - 它打开
PrimaryStory,截了一张图。 - 它把这张图上传到 Chromatic 的服务器,命名为
Button-Primary-1.png。
- Chromatic 扫描
- 对比:
- Chromatic 服务器检索历史记录,找到了
Button-Primary-1.png。 - 它把两张图拿去比(像素级对比)。
- Chromatic 服务器检索历史记录,找到了
- 结果:
- 发现差异:圆角变了。
- 差异百分比:0.5% (超过了 0.01%)。
- 报错:CI 管道失败,并在 PR 页面生成一个红色的 Diff 图。
你点开 Diff 图,看到左边的按钮是圆角的,右边是直角的。你叹了口气,意识到自己改错了地方(或者这确实是必要的修改)。你修改代码,重新提交。CI 再次运行,Diff 消失了,绿色通过。
第八章:Puppeteer 手动实现——当 SaaS 搞不定你时
虽然 Chromatic 很好用,但有时候你需要更细粒度的控制,或者你想省那点订阅费。这时候,Puppeteer 就是你的首选。
Puppeteer 是一个 Node 库,它提供了一个高级 API 来控制 Chrome 或 Chromium 浏览器。你可以用它来截图。
基本思路:
- 启动一个本地 Storybook(或者直接在 CI 里启动)。
- Puppeteer 访问 Storybook 的 URL。
- 查找 DOM 元素。
- 截图。
- 保存到
screenshots文件夹。 - 使用
pixelmatch库对比当前截图和基准截图。
代码示例:
首先,安装依赖:
npm install --save-dev puppeteer pixelmatch
然后写一个脚本 visual-test.js:
const puppeteer = require('puppeteer');
const fs = require('fs');
const path = require('path');
const pixelmatch = require('pixelmatch');
// 配置
const STORYBOOK_URL = 'http://localhost:6006';
const SCREENSHOT_DIR = './screenshots';
const BASELINE_DIR = './baseline';
async function runVisualTests() {
// 1. 启动浏览器
const browser = await puppeteer.launch({
headless: 'new', // CI 环境下建议用 headless
args: ['--no-sandbox', '--disable-setuid-sandbox'] // Docker 环境必备
});
const page = await browser.newPage();
// 2. 等待 Storybook 加载
await page.goto(STORYBOOK_URL, { waitUntil: 'networkidle0' });
// 3. 截图函数
const captureScreenshot = async (name) => {
// 查找对应的 Story 元素。这里假设我们有一个特定的容器
// 实际上你可能需要通过 ID 或 data-testid 查找
await page.waitForSelector(`[data-testid="${name}"]`);
const element = await page.$(`[data-testid="${name}"]`);
const file = path.join(SCREENSHOT_DIR, `${name}.png`);
await element.screenshot({ path: file });
return file;
};
// 4. 执行测试
// 假设我们有一个 Button 组件
const currentButton = await captureScreenshot('button-primary');
const baselineButton = path.join(BASELINE_DIR, 'button-primary.png');
// 检查基准图是否存在
if (!fs.existsSync(baselineButton)) {
console.log('No baseline found. Creating one...');
fs.mkdirSync(BASELINE_DIR, { recursive: true });
fs.copyFileSync(currentButton, baselineButton);
console.log('Baseline created. Please review manually.');
await browser.close();
return;
}
// 读取图片数据
const img1 = fs.readFileSync(currentButton);
const img2 = fs.readFileSync(baselineButton);
const width = img1.length;
const height = 4 * width / 5; // 假设宽高比
const diff = Buffer.alloc(width * height);
// 执行像素比对
const numDiffPixels = pixelmatch(img1, img2, diff, width, height, { threshold: 0.1 });
// 保存差异图
const diffFile = path.join(SCREENSHOT_DIR, `diff-button-primary.png`);
fs.writeFileSync(diffFile, diff);
if (numDiffPixels > 0) {
console.error(`Visual regression detected! ${numDiffPixels} pixels differ. Check ${diffFile}`);
process.exit(1);
} else {
console.log('Visual regression test passed!');
}
await browser.close();
}
runVisualTests().catch(err => {
console.error(err);
process.exit(1);
});
这段代码看起来很原始,但它给了你绝对的自由。你可以控制等待时间,可以处理复杂的布局,甚至可以在截图前执行一些 JavaScript 代码。
第九章:总结与心态建设
好了,讲了这么多,我们来聊聊心态。
视觉回归测试不是用来羞辱你的,它是用来保护你的。
- 不要害怕失败:刚开始集成 VRT 的时候,你会看到满屏的红色。别慌,那是正常的。那是 CI 在告诉你:“嘿,这里有个变化。”
- 建立信心:当你看到 Diff 图,确认那确实是一个你不想要的变化时,你会感谢 VRT 的。
- 不要过度依赖:VRT 只能检测视觉变化。它不能检测逻辑错误,也不能检测用户体验的糟糕。它只是 UI 的最后一道防线。
最后,记住这句话:代码是写给机器看的,UI 是写给人类看的。 如果你的 UI 看起来不对劲,那就是机器赢了,人类输了。而我们的任务,就是利用 CI 和 VRT,让机器保持冷静,让人类保持开心。
所以,去安装 Storybook,去集成 Chromatic,去拥抱那些像素吧!愿你的 CI 管道永远绿得像春天的草地!
谢谢大家!