视觉回归测试:一场关于像素的“战争”与“和平”
各位好,欢迎来到今天的讲座。
我是你们的老朋友,一个每天在代码里和 CSS 辩论,在浏览器里和像素较劲的资深前端工程师。今天我们不聊 React 的生命周期,也不聊 Redux 的中间件,我们要聊一个稍微有点“玄学”,但绝对关乎你职业生涯幸福感的话题——视觉回归测试。
想象一下这个场景:你刚刚修复了一个 Bug,你兴奋地打开浏览器,刷新页面,指着屏幕对产品经理说:“看,这个按钮现在变红了,完美!”产品经理点点头,你也松了一口气,提交代码,合并,部署。然后,你打开了生产环境的网站……
那一瞬间,你的笑容凝固了。按钮是红色的,没错。但是,它的位置偏了 3 个像素,字体的大小比你的设计稿大了 2 像素,背景色在 Retina 屏幕上看起来灰蒙蒙的。你的眼睛在撒谎,你的大脑在美化,只有那个冷酷无情的浏览器像素,诚实地记录了你的失败。
这就是我们要解决的问题。今天,我们要用一种名为 Chromatic 的魔法武器,来驯服这些像素怪兽。
第一章:为什么我们需要“照妖镜”?
首先,我们要搞清楚一个概念。自动化测试通常分两类:功能测试和视觉测试。
功能测试是问你的组件:“嘿,这个按钮能被点击吗?点击后状态对吗?”这是理性的,是逻辑的。
视觉测试是问你的组件:“嘿,你长得跟上次一样吗?有没有长歪?”这是感性的,是审美的。
很多团队,尤其是初创团队,一开始是拒绝视觉测试的。为什么?因为觉得它慢,觉得它麻烦,觉得“我眼睛看着没问题就行”。这就好比你自己照镜子,觉得自己挺帅的,但旁边站个美女/帅哥,她一眼就能看出你发型乱了。
视觉回归测试(VRT)的核心逻辑很简单:截图,对比,报警。
如果你在 React 里修改了一个组件,导致它的视觉表现发生了微小的变化,Chromatic 会像雷达一样探测到这个变化,并在你的 Pull Request 上贴上一张图,告诉你:“嘿,左边是你改之前的,右边是现在的,红色部分就是不一样的地方。”
这就好比给你的代码配了一个“照妖镜”,不管你怎么改,只要长得不对,它就报警。
第二章:Chromatic 是个什么神仙?
Chromatic 是 Storybook 的官方姊妹产品。如果你不知道 Storybook,那你现在的职业生涯可能少了一半的快乐。Storybook 是一个组件开发环境,它让你能像操作乐高积木一样,单独测试每一个 React 组件。
而 Chromatic,就是给 Storybook 装上的一双眼睛。它把你的 Storys(组件的各种状态)拍下来,存到云端,然后当你修改代码时,它会自动比对。如果发现差异,它会生成一个“差异图”。
简单来说:
- Storybook 是你的画室。
- Chromatic 是你的画廊馆长,负责检查你的画有没有画歪。
第三章:搭建战场——安装与配置
好了,理论讲完了,我们开始动手。假设你已经有了 React 项目,并且已经安装了 Storybook。如果你没有,别慌,这就像做饭前要先洗菜一样简单。
3.1 安装 Chromatic 插件
我们需要告诉 Storybook:“嘿,老伙计,我们要开始搞视觉测试了,请把 Chromatic 插件加进来。”
在项目根目录打开终端,运行:
npm install --save-dev @storybook/addon-chromatic
或者如果你用 Yarn:
yarn add --dev @storybook/addon-chromatic
3.2 注册插件
接下来,我们需要告诉 Storybook 启动这个插件。找到你的 .storybook/main.js(或者 .ts,取决于你的配置文件类型)文件。
你需要导入 Chromatic 插件,并在 addons 数组里注册它。同时,我们要启用 chromatic 这个参数,这就像是告诉 Chromatic:“请开始你的表演”。
// .storybook/main.js
import { configure } from '@storybook/react';
import path from 'path';
// 引入 Chromatic 插件
import chromatic from '@storybook/addon-chromatic';
// ... 你的其他配置 ...
// 注册插件
function loadStories() {
const req = require.context('../src', true, /.stories.js$/);
req.keys().forEach(filename => req(filename));
}
// 配置 Storybook
configure(loadStories, module);
// 重要:启用 Chromatic
export const parameters = {
// ...
chromatic: {
// 这里可以配置一些参数,比如自动截图的分辨率等
// 在实际项目中,通常不需要改,直接用默认值
},
};
3.3 启动 Storybook
现在,运行你的 Storybook:
npm run storybook
当你打开 Storybook 的界面时,你会发现右上角多了一个 Chromatic 的图标。点击它,你会看到一个上传按钮。这就是你的第一个“视觉快照”。
点击上传,Chromatic 会把你的组件截图发送到云端,并给你一个链接。这时候,你就可以关闭 Storybook,去写代码了。
第四章:编写 Storys——组件的“全家福”
视觉测试的基础是 Storys。如果你没有写 Storys,Chromatic 就没法截图。这就好比你想让摄影师给你拍照,但你连衣服都不换,直接站在那里,摄影师也没法拍啊。
4.1 基础 Story 示例
让我们写一个简单的按钮组件 Button.js,以及对应的 Button.stories.js。
Button.js:
import React from 'react';
export const Button = ({ children, variant = 'primary' }) => {
const style = {
padding: '10px 20px',
border: 'none',
borderRadius: '5px',
cursor: 'pointer',
backgroundColor: variant === 'primary' ? '#007bff' : '#6c757d',
color: 'white',
};
return (
<button style={style}>
{children}
</button>
);
};
Button.stories.js:
import React from 'react';
import { Button } from './Button';
export default {
title: 'Atoms/Button',
component: Button,
};
export const Primary = () => <Button variant="primary">Primary Button</Button>;
export const Secondary = () => <Button variant="secondary">Secondary Button</Button>;
export const Large = () => <Button style={{ fontSize: '24px' }}>Large Button</Button>;
这里,我们定义了三个 Story:Primary(主要按钮),Secondary(次要按钮),Large(大按钮)。Chromatic 会自动把这些 Story 截图,作为基准图。
第五章:交互测试——不仅仅是静止的画
这可能是很多初学者最容易踩坑的地方。视觉测试不仅仅是拍静态图。如果你的按钮有点击效果,或者模态框有打开动画,你需要在 Storys 里模拟这些交互。
Chromatic 提供了一个非常强大的 Play 函数。它允许你在 Story 渲染出来之后,模拟用户的操作,比如点击、输入、滚动等。
5.1 模拟点击
假设我们有一个 Toggle 组件,它有一个开关,可以切换“开”和“关”的状态。我们需要确保切换状态时,视觉上没有崩坏。
Toggle.stories.js:
import React, { useState } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { Toggle } from './Toggle';
const meta = {
title: 'Atoms/Toggle',
component: Toggle,
} satisfies Meta<typeof Toggle>;
export default meta;
type Story = StoryObj<typeof meta>;
export const On: Story = {
render: () => <Toggle checked={true} onChange={() => {}} />,
};
export const Off: Story = {
render: () => <Toggle checked={false} onChange={() => {}} />,
};
// 进阶:使用 Play 函数模拟交互
export const InteractiveToggle: Story = {
render: () => {
const [checked, setChecked] = useState(false);
return <Toggle checked={checked} onChange={() => setChecked(!checked)} />;
},
play: async ({ canvasElement }) => {
const canvas = await canvasElement;
const button = canvas.getByRole('button');
// 模拟第一次点击,状态变为 On
await button.click();
// 模拟第二次点击,状态变为 Off
await button.click();
// 验证状态是否正确
// 注意:这里我们不需要写断言,因为 Chromatic 会自动截图并比对
// 只要点击后页面没有报错,样式没有崩坏,就算通过
},
};
看到 play 函数了吗?它就像是给组件加了自动化的“手”。你在里面写的任何代码,都会在截图之前执行。
5.2 处理异步状态
如果你的组件有异步请求,比如加载状态,或者弹窗延迟出现,play 函数也要配合 await 使用。
export const AsyncComponent: Story = {
render: () => <AsyncButton />,
play: async ({ canvasElement }) => {
const canvas = await canvasElement;
const button = canvas.getByText('Load Data');
// 点击按钮
await button.click();
// 等待 Loading 消失(假设有 loading 状态)
await waitFor(() => {
expect(canvas.getByText('Data Loaded')).toBeInTheDocument();
});
// 此时再截图,才能确保截图包含了加载后的状态
},
};
第六章:CI/CD 集成——让自动化成为肌肉记忆
如果你只是在本地运行 npm run storybook,那 Chromatic 只是个玩具。真正的威力在于把它集成到你的 CI/CD 流程中。
当你的同事提交了一个 PR,Chromatic 应该自动运行,检查你的改动是否破坏了视觉一致性。如果破坏了,PR 就不应该被合并。
6.1 获取 Token
首先,你需要去 Chromatic 的官网注册账号,然后创建一个项目。它会给你一个 project-token。这个 Token 就像是你的通行证,用来告诉 Chromatic “我是谁,我要测试什么”。
6.2 GitHub Actions 配置
假设你用 GitHub Actions,创建一个 .github/workflows/storybook.yml 文件。
name: Storybook Chromatic
on:
pull_request:
branches: [ main ]
push:
branches: [ main ]
jobs:
chromatic:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
# 这一步很重要,Chromatic 需要完整的 Git 历史来计算差异
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Install Storybook
run: npx storybook@latest init --yes
- name: Install Chromatic
run: npm install --save-dev @storybook/addon-chromatic
- name: Build Storybook
run: npx build-storybook --quiet
- name: Deploy to Chromatic
# 这里替换成你自己的 token
env:
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
run: npx chromatic --project-token=$CHROMATIC_PROJECT_TOKEN
这段代码做了什么?
- 检出代码。
- 安装 Node.js。
- 安装依赖。
- 初始化 Storybook(为了构建 Storybook)。
- 安装 Chromatic。
- 构建静态的 Storybook。
- 运行 Chromatic,并将结果上传到云端。
一旦你提交这段代码,每次有人 PR,GitHub Actions 就会自动运行。如果你的 PR 破坏了视觉,Chromatic 会返回一个失败状态,PR 上会显示红色的“Failed”标签。
第七章:高级技巧——处理那些“刁钻”的像素
在实际项目中,你一定会遇到一些 Chromatic 报错说“有差异”,但你自己看完全没问题的场景。这通常是因为以下原因。
7.1 自动裁剪
这是 Chromatic 最强大的功能之一。有时候,你的组件在 1000px 宽的屏幕上显示正常,但在 400px 的手机屏幕上,内容溢出了。如果你不裁剪,Chromatic 会认为这是一个 Bug。
Chromatic 会自动尝试计算可视区域,只截图组件可见的部分。你只需要在配置中开启它:
// .storybook/main.js
export const parameters = {
chromatic: {
autoIncrementDays: 7, // 自动增加构建次数,避免误报
disableSnapshots: false,
// 开启自动裁剪
disableSnapshot: false,
// 某些情况下,你可能需要手动指定裁剪区域
// cropThreshold: 0.1,
},
};
7.2 忽略特定差异
有时候,你的组件里有一些动态生成的数据,比如“今天是星期三”,每次运行都会变。或者你用了 Date.now() 作为随机种子。这些都会导致视觉差异。
Chromatic 允许你忽略某些特定的差异。
在 Storys 文件中,你可以添加 parameters.chromatic.ignoreSnapshot:
export const DynamicDate: Story = {
render: () => <div>{new Date().toDateString()}</div>,
parameters: {
chromatic: {
ignoreSnapshot: true, // 告诉 Chromatic:这个组件每次都不一样,别报错。
},
},
};
或者,你可以使用 CSS 来忽略差异。比如,如果你的按钮背景色在某些浏览器下有细微差别(这是 CSS 兼容性的锅,不是你的锅),你可以使用 storybook-addon-pseudo-states 或者直接在 CSS 里写死颜色。
7.3 伪状态测试
很多组件在不同状态下表现不同。比如按钮在 Hover、Focus、Disabled 状态下应该有不同的样式。
你可以手动写多个 Story 来测试这些状态:
export const Primary: Story = () => <Button>Primary</Button>;
export const PrimaryHover: Story = () => <Button>Primary</Button>;
PrimaryHover.parameters = { pseudo: { hover: true } }; // 利用 Storybook 的 pseudo 参数
export const PrimaryDisabled: Story = () => <Button disabled>Primary</Button>;
这样,Chromatic 会自动生成 Hover 和 Disabled 状态的截图,并作为基准图。
第八章:心态建设——不要被测试绑架
讲了这么多,最后我要泼一盆冷水。视觉回归测试虽然好,但它不是万能的,也不是完美的。
- 不要过度测试:如果你的按钮组件有 100 个 Story,每个 Story 都截图,那 CI 的时间会很长。只测试核心路径和关键状态。
- 不要陷入细节:有时候,Chromatic 会报一个“差异”,实际上只是字体渲染的微小差别。这时候,不要盲目地去改代码,先问自己:这个差异用户看得到吗?如果看得到,是不是应该改设计稿?
- 它是工具,不是老板:Chromatic 是你的助手,不是你的老板。如果你的 PR 被 Chromatic 拒绝了,不要愤怒。去查看差异图,分析原因,如果是误报,就忽略它;如果是真有问题,就修复它。
第九章:实战案例——重构一个复杂的卡片组件
为了巩固一下,我们来实战一下。假设我们有一个 UserProfileCard 组件,它包含头像、名字、简介和几个操作按钮。
9.1 组件代码
// UserProfileCard.js
import React from 'react';
import { Avatar } from './Avatar';
export const UserProfileCard = ({ user }) => {
return (
<div style={styles.card}>
<div style={styles.header}>
<Avatar src={user.avatar} alt={user.name} />
<h2>{user.name}</h2>
</div>
<p style={styles.bio}>{user.bio}</p>
<div style={styles.actions}>
<button style={styles.btn}>Edit</button>
<button style={styles.btn}>Delete</button>
</div>
</div>
);
};
const styles = {
card: {
border: '1px solid #ddd',
borderRadius: '8px',
padding: '20px',
maxWidth: '300px',
boxShadow: '0 2px 5px rgba(0,0,0,0.1)',
},
header: {
display: 'flex',
alignItems: 'center',
marginBottom: '10px',
},
bio: {
color: '#666',
marginBottom: '20px',
},
actions: {
display: 'flex',
justifyContent: 'flex-end',
gap: '10px',
},
btn: {
padding: '5px 10px',
cursor: 'pointer',
},
};
9.2 编写 Storys
// UserProfileCard.stories.js
import React from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { UserProfileCard } from './UserProfileCard';
const meta = {
title: 'Components/UserProfileCard',
component: UserProfileCard,
} satisfies Meta<typeof UserProfileCard>;
export default meta;
type Story = StoryObj<typeof meta>;
const user = {
name: 'Alice Wonder',
avatar: 'https://via.placeholder.com/100',
bio: 'Frontend Developer and CSS wizard.',
};
// 基础状态
export const Default: Story = {
render: () => <UserProfileCard user={user} />,
};
// 有趣的状态
export const LongBio: Story = {
render: () => (
<UserProfileCard
user={{
...user,
bio: 'This is a very long bio that might wrap to multiple lines. We want to make sure the card height adjusts correctly and the layout doesn't break. Visual regression testing helps us catch these layout shifts.',
}}
/>
),
};
// 交互状态
export const WithInteractions: Story = {
render: () => <UserProfileCard user={user} />,
play: async ({ canvasElement }) => {
const canvas = await canvasElement;
const editBtn = canvas.getByText('Edit');
const deleteBtn = canvas.getByText('Delete');
// 点击 Edit
await editBtn.click();
// 模拟一个弹窗或者 Toast 提示
// 此时我们再截图,确保弹窗显示正常
},
};
9.3 运行与观察
当你运行 Chromatic 时,你会看到三个 Story 被截图。然后,你修改了 UserProfileCard 的样式,比如把 border-radius 从 8px 改成了 50px。
再次运行 Chromatic,你会看到一张差异图。差异图会用红色标出圆角变化的地方。你点击差异图,可以一键修复,或者一键忽略。
第十章:总结与展望
好了,今天的讲座就到这里。
我们回顾一下:为什么我们需要视觉回归测试?因为人类的眼睛会骗人,但像素不会。我们如何实现它?通过 Storybook + Chromatic。我们如何配置?安装插件,注册,上传 Token。我们如何写测试?写 Storys,用 Play 函数模拟交互。我们如何集成?GitHub Actions。
视觉回归测试不是银弹。它不能替代功能测试,也不能替代手动测试。但它是一个强大的辅助工具,它能让你在重构代码时更有底气,能让你在发布新功能时更安心。
当你看到你的 CI/CD 流水线上,Chromatic 的图标是绿色的,你知道,你的组件不仅逻辑正确,而且长得也很漂亮。
最后,送给大家一句话:
代码是写给人看的,只是顺便给机器运行。但视觉测试,是给机器看的,顺便给人类看的。
祝大家的 Pull Request 都能顺利通过,祝大家的按钮永远不偏移,祝大家的视觉回归测试永远不报错!
谢谢大家!