React 持续集成:在 CI 流程中集成 React 组件的视觉回归测试(Visual Regression)

各位同学,大家好,欢迎来到今天的讲座。我是你们的“像素守护者”,也是那个每次看到 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 生态里,做视觉测试,我们通常不是自己写一个图像处理算法(除非你想发一篇顶会论文),而是用现成的工具。主要有这么几把锤子:

  1. Percy (现在属于 GitLab): 以前是独立的大佬,现在跟 GitLab 联姻了。它的界面非常漂亮,像是在看艺术展。
  2. Chromatic: Storybook 官方推荐,也是目前最流行的。它跟 Storybook 的集成简直是“天作之合”,甚至可以说“这俩是亲生的”。
  3. 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 面板,你可以在这里切换 PrimarySecondary


第四章: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,它依赖了 ButtonInput

  1. 提交代码:你改了 Button 的圆角,从 4px 改成了 8px
  2. 触发 CI:GitHub Actions 开始运行。
  3. 执行步骤
    • npm ci:安装依赖。
    • npm run build-storybook:构建 Storybook。这时候,Storybook 会编译所有的组件,包括 Button
    • npx chromatic:Chromatic 启动一个无头浏览器(Headless Browser),打开 Storybook。
  4. 截图与上传
    • Chromatic 扫描 Button.stories.tsx
    • 它发现了 PrimarySecondary 两个 Story。
    • 它打开 Primary Story,截了一张图。
    • 它把这张图上传到 Chromatic 的服务器,命名为 Button-Primary-1.png
  5. 对比
    • Chromatic 服务器检索历史记录,找到了 Button-Primary-1.png
    • 它把两张图拿去比(像素级对比)。
  6. 结果
    • 发现差异:圆角变了。
    • 差异百分比:0.5% (超过了 0.01%)。
    • 报错:CI 管道失败,并在 PR 页面生成一个红色的 Diff 图。

你点开 Diff 图,看到左边的按钮是圆角的,右边是直角的。你叹了口气,意识到自己改错了地方(或者这确实是必要的修改)。你修改代码,重新提交。CI 再次运行,Diff 消失了,绿色通过。


第八章:Puppeteer 手动实现——当 SaaS 搞不定你时

虽然 Chromatic 很好用,但有时候你需要更细粒度的控制,或者你想省那点订阅费。这时候,Puppeteer 就是你的首选。

Puppeteer 是一个 Node 库,它提供了一个高级 API 来控制 Chrome 或 Chromium 浏览器。你可以用它来截图。

基本思路:

  1. 启动一个本地 Storybook(或者直接在 CI 里启动)。
  2. Puppeteer 访问 Storybook 的 URL。
  3. 查找 DOM 元素。
  4. 截图。
  5. 保存到 screenshots 文件夹。
  6. 使用 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 代码。


第九章:总结与心态建设

好了,讲了这么多,我们来聊聊心态。

视觉回归测试不是用来羞辱你的,它是用来保护你的。

  1. 不要害怕失败:刚开始集成 VRT 的时候,你会看到满屏的红色。别慌,那是正常的。那是 CI 在告诉你:“嘿,这里有个变化。”
  2. 建立信心:当你看到 Diff 图,确认那确实是一个你不想要的变化时,你会感谢 VRT 的。
  3. 不要过度依赖:VRT 只能检测视觉变化。它不能检测逻辑错误,也不能检测用户体验的糟糕。它只是 UI 的最后一道防线。

最后,记住这句话:代码是写给机器看的,UI 是写给人类看的。 如果你的 UI 看起来不对劲,那就是机器赢了,人类输了。而我们的任务,就是利用 CI 和 VRT,让机器保持冷静,让人类保持开心。

所以,去安装 Storybook,去集成 Chromatic,去拥抱那些像素吧!愿你的 CI 管道永远绿得像春天的草地!

谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注