使用 DVC 管理 Storybook 可视化测试快照并集成 CircleCI 实现自动化回归


当团队维护一个超过三百个组件的设计系统时,视觉回归测试的快照管理很快就从一个工程问题演变成了一场灾难。最初,我们将 Jest Image Snapshot 生成的基准图片(baseline snapshots)直接提交到 Git 仓库。这在项目初期看似可行,但随着组件和测试用例的增加,.git 目录迅速膨胀,git clone 的时间从几十秒恶化到数分钟。更糟糕的是,Pull Request 中的二进制图片差异对比毫无意义,除了“文件已更改”,我们得不到任何有效信息。

我们尝试过 Git LFS,但它与我们的工作流耦合得太紧,且在处理大量小文件时性能并不理想。我们需要一个方案,能将快照数据与代码仓库解耦,同时提供清晰的版本控制,并且能无缝地集成到现有的 CircleCI 流水线中。这不仅仅是存储问题,而是如何构建一个健壮、可维护、自动化的视觉质量保障体系。

初步构想与技术选型决策

我们的核心痛点非常明确:

  1. Git 仓库污染: 二进制快照文件不应与源代码混在一起。
  2. 版本控制缺失: 快照的变更历史难以追溯,无法轻易回滚到某个“已知良好”的视觉基准。
  3. 协作困难: 更新基准快照的流程繁琐且容易出错,尤其是在多人协作的分支上。
  4. CI 效率低下: 拉取庞大的仓库严重拖慢了 CI/CD 流程。

方案的核心是将这些视觉快照视为“数据”而非“代码”。这个思维转变让我们把目光投向了 DVC (Data Version Control)。DVC 的设计哲学就是用 Git 的方式来管理数据,但数据本身存储在外部(如 S3, GCS)。它在 Git 中只保存轻量的元数据文件(.dvc 文件),指向实际的数据版本。

这个选型非常契合我们的场景:

  • DVC: 负责快照的存储和版本管理。main 分支的快照就是我们的“生产数据”,分支上的快照则是“实验数据”。
  • Storybook & jest-image-snapshot: 负责生成 UI 组件的渲染实例并进行像素级对比,产出视觉快照和差异报告。
  • CircleCI: 作为总调度中心,编排整个流程:拉取代码、拉取基准快照、运行测试、根据分支逻辑决定是进行对比还是更新基准。

这种组合拳能解决所有痛点。代码库会保持轻量,CI 速度得以保证,快照版本和代码版本通过 Git commit 精确对应,协作流程也变得清晰。

步骤化实现:从本地环境到自动化流水线

让我们从一个真实项目的配置开始。假设我们有一个基于 React 和 TypeScript 的组件库。

1. 本地环境配置

首先,确保项目依赖是完整的。在真实项目中,package.json 可能如下所示:

// package.json
{
  "name": "design-system-visual-testing",
  "version": "1.0.0",
  "scripts": {
    "storybook": "storybook dev -p 6006",
    "build-storybook": "storybook build",
    "test-storybook:ci": "test-storybook --url http://localhost:6006 --maxWorkers=2 --retries=1",
    "test-storybook:update": "test-storybook --url http://localhost:6006 -u"
  },
  "devDependencies": {
    "@babel/preset-env": "^7.22.10",
    "@babel/preset-react": "^7.22.5",
    "@babel/preset-typescript": "^7.22.5",
    "@storybook/addon-essentials": "^7.4.0",
    "@storybook/react": "^7.4.0",
    "@storybook/react-webpack5": "^7.4.0",
    "@storybook/test-runner": "^0.13.0",
    "dvc": "^3.0.0",
    "jest": "^29.6.4",
    "jest-image-snapshot": "^6.2.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "storybook": "^7.4.0",
    "typescript": "^5.2.2"
  }
}

接下来是配置 Storybook 的测试运行器。我们需要一个自定义的 Jest 配置来集成 jest-image-snapshot

.storybook 目录下创建 test-runner.js

// .storybook/test-runner.js
const { toMatchImageSnapshot } = require('jest-image-snapshot');
const { getStoryContext } = require('@storybook/test-runner');

// 增加超时时间,防止因组件复杂或机器性能不足导致测试失败
const JEST_TIMEOUT = 30000; // 30 seconds

module.exports = {
  // 测试前的配置
  setup() {
    // 这里的 expect 是 jest 的全局变量,我们为其扩展 toMatchImageSnapshot 方法
    expect.extend({ toMatchImageSnapshot });
    // 为每个测试用例设置更长的超时时间
    jest.setTimeout(JEST_TIMEOUT);
  },

  // 每个 story 执行的主要测试逻辑
  async postRender(page, context) {
    const storyContext = await getStoryContext(page, context);

    // 跳过禁用了视觉快照测试的 story
    if (storyContext.parameters?.storyshots?.disable) {
      return;
    }

    const image = await page.screenshot({
      // 确保截取的区域仅为组件本身,而非整个 Storybook UI
      clip: (await page.$('#storybook-root'))?.boundingBox()
    });

    // 一个常见的错误是在这里不提供 customSnapshotsDir。
    // 如果不指定,快照会散落在各个组件目录下,不利于 DVC 统一管理。
    // 我们强制所有快照都存放在一个顶级目录中。
    expect(image).toMatchImageSnapshot({
      customSnapshotsDir: 'image_snapshots',
      customSnapshotIdentifier: context.id,
      // 容错阈值,生产项目中设为0可能过于严苛,导致因抗锯齿等细微差别而失败。
      // 0.01% 是一个比较合理的起点。
      failureThreshold: 0.0001,
      failureThresholdType: 'percent',
    });
  },
};

这个配置的核心是将所有快照统一输出到项目根目录下的 image_snapshots 文件夹,这对于后续 DVC 的追踪至关重要。

2. DVC 集成与数据版本化

现在,我们引入 DVC 来管理 image_snapshots 目录。

首先,初始化 DVC 并配置远程存储。我们使用 AWS S3 作为后端。

# 安装 DVC CLI (通常在 CI 环境中需要)
# pip install dvc[s3]

# 初始化 DVC
dvc init

# 添加一个 S3 远程存储,命名为 's3-snapshots'
# 这里的 'my-design-system-snapshots' 是你的 S3 bucket 名称
dvc remote add -d s3-snapshots s3://my-design-system-snapshots/snapshots

# 配置 DVC 使用这个默认远程
dvc config core.remote s3-snapshots

接下来,我们需要告诉 DVC 追踪 image_snapshots 目录。在 main 分支上,我们首先生成一套基准快照。

# 运行 storybook 服务
npm run storybook &
STORYBOOK_PID=$!

# 等待 storybook 启动
sleep 15 

# 生成初始快照。-u 代表 --updateSnapshot
npm run test-storybook:update

# 关闭 storybook 服务
kill $STORYBOOK_PID

# 使用 DVC 添加快照目录
dvc add image_snapshots

执行 dvc add 后,DVC 会做几件事:

  1. image_snapshots 目录的内容移动到 .dvc/cache 中。
  2. 创建一个名为 image_snapshots.dvc 的元数据文件。这个文件很小,记录了目录内容的哈希值和结构。
  3. 自动更新 .gitignore,将 image_snapshots/ 添加进去,确保原始图片不会被 Git 追踪。

现在,我们可以提交这些变更并推送数据到 S3。

# 将 DVC 元数据文件和 .gitignore 提交到 Git
git add image_snapshots.dvc .gitignore
git commit -m "feat(visuals): Add initial DVC-managed snapshots"

# 推送数据到 S3 远程存储
dvc push

至此,任何有权限访问 S3 bucket 的开发者都可以通过 dvc pull 命令拉取到与当前 Git commit 完全匹配的视觉基准快照。

3. CircleCI 自动化工作流设计

这是整个体系的核心。我们需要设计一个能够区分不同分支逻辑的 CI 工作流。

  • 对于特性分支 (feature branches): 拉取 main 分支的基准快照,运行对比测试。如果存在视觉差异,流水线失败。
  • 对于 main 分支: 在合并了更新基准的 PR 后,自动运行更新快照的流程,并将新的快照版本(即更新后的 .dvc 文件)提交回 main 分支,再将数据推送到 S3。

下面是一个生产级的 .circleci/config.yml 文件:

# .circleci/config.yml
version: 2.1

orbs:
  node: circleci/[email protected]

# 定义可复用的命令
commands:
  # 安装项目依赖和 DVC
  setup_environment:
    steps:
      - checkout
      - node/install-packages:
          pkg-manager: npm
          cache-key: "package-json-{{ .Branch }}-{{ checksum \"package.json\" }}"
      - run:
          name: "Install Python dependencies (DVC)"
          command: |
            sudo apt-get update
            sudo apt-get install -y python3-pip
            pip3 install dvc[s3]

  # 配置 AWS 和 DVC
  configure_dvc:
    steps:
      - run:
          name: "Configure AWS credentials for DVC"
          command: |
            # 从 CircleCI 的环境变量中获取 AWS 凭证
            echo "[default]" > ~/.aws/credentials
            echo "aws_access_key_id = $AWS_ACCESS_KEY_ID" >> ~/.aws/credentials
            echo "aws_secret_access_key = $AWS_SECRET_ACCESS_KEY" >> ~/.aws/credentials
            echo "[default]" > ~/.aws/config
            echo "region = $AWS_REGION" >> ~/.aws/config
      - run:
          name: "Configure DVC remote"
          command: |
            dvc remote modify s3-snapshots endpointurl https://s3.${AWS_REGION}.amazonaws.com

# 定义执行器
executors:
  node_executor:
    docker:
      - image: cimg/node:18.17-browsers # 包含 Node.js 和浏览器环境

jobs:
  # 在特性分支上运行视觉对比测试
  run_visual_regression_test:
    executor: node_executor
    steps:
      - setup_environment
      - configure_dvc
      - run:
          name: "Pull baseline snapshots from main branch"
          command: |
            # 这里的坑在于:不能直接 dvc pull。
            # 因为当前分支的 image_snapshots.dvc 可能已经和 main 不一样了。
            # 我们需要强制拉取 main 分支对应的快照数据。
            # 首先,从 main 分支获取 .dvc 文件
            git show main:image_snapshots.dvc > image_snapshots.dvc
            dvc pull -f image_snapshots.dvc
      - run:
          name: "Build Storybook static files"
          command: npm run build-storybook
      - run:
          name: "Run Storybook visual regression tests"
          # 使用 npx concurrently 来并行启动服务和测试,并确保服务在测试结束后能被关闭
          command: |
            npx concurrently \
              --kill-others-on-fail \
              --prefix name \
              --names "SB,TEST" \
              -c "bgBlue.bold,bgMagenta.bold" \
              "npx http-server storybook-static -p 6006 --silent" \
              "wait-on http://localhost:6006 && npm run test-storybook:ci"

  # 在 main 分支上更新基准快照
  update_visual_snapshots_on_main:
    executor: node_executor
    steps:
      - setup_environment
      - configure_dvc
      - run:
          name: "Pull latest snapshots for main"
          # 拉取当前 commit 的快照,确保我们基于最新的基准进行更新
          command: dvc pull
      - run:
          name: "Build Storybook static files"
          command: npm run build-storybook
      - run:
          name: "Update visual snapshots"
          command: |
            npx concurrently \
              --kill-others-on-fail \
              --prefix name \
              --names "SB,TEST" \
              -c "bgBlue.bold,bgMagenta.bold" \
              "npx http-server storybook-static -p 6006 --silent" \
              "wait-on http://localhost:6006 && npm run test-storybook:update"
      - run:
          name: "Commit and push updated DVC data and metadata"
          command: |
            # 检查是否有快照变化
            if ! git diff --quiet image_snapshots.dvc; then
              echo "Snapshots have changed, pushing updates."
              
              # 推送数据到 S3
              dvc push
              
              # 提交 .dvc 文件变更
              git config user.email "[email protected]"
              git config user.name "CircleCI Bot"
              git add image_snapshots.dvc
              # [skip ci] 是为了防止这个提交触发新的 CI 循环
              git commit -m "chore(ci): Update visual regression snapshots [skip ci]"
              git push origin main
            else
              echo "No visual changes detected. Nothing to update."
            fi

workflows:
  version: 2
  visual_testing_workflow:
    jobs:
      - run_visual_regression_test:
          filters:
            branches:
              ignore:
                - main
      - update_visual_snapshots_on_main:
          requires:
            - run_visual_regression_test # 逻辑上 main 分支的更新应该是在 PR 合并后
          filters:
            branches:
              only:
                - main

这个 CircleCI 配置是整个系统的自动化核心。它通过分支过滤器将两种不同的操作(测试与更新)清晰地分离开来。

4. 可视化工作流

为了更清晰地展示这个流程,我们可以使用 Mermaid 图来描述。

graph TD
    A[Developer pushes to feature branch] --> B{CircleCI Triggered};
    B --> C[Job: run_visual_regression_test];
    C --> D[Git Checkout];
    D --> E[git show main:image_snapshots.dvc];
    E --> F[dvc pull -f];
    F --> G[Run Visual Tests];
    G --> H{Changes Detected?};
    H -- Yes --> I[Pipeline Fails, PR Blocked];
    H -- No --> J[Pipeline Succeeds, PR Can Be Merged];
    
    subgraph Feature Branch Workflow
        A
        B
        C
        D
        E
        F
        G
        H
        I
        J
    end

    K[Developer merges PR to main] --> L{CircleCI Triggered};
    L --> M[Job: update_visual_snapshots_on_main];
    M --> N[Git Checkout];
    N --> O[dvc pull];
    O --> P[Run Tests with Update Flag];
    P --> Q[dvc push];
    Q --> R[git commit & push .dvc file];
    
    subgraph Main Branch Workflow
        K
        L
        M
        N
        O
        P
        Q
        R
    end

最终成果与流程闭环

通过这套体系,我们实现了一个完整的闭环:

  1. 开发阶段: 开发者在本地修改组件,可以运行 npm run test-storybook:update 来更新快照,并通过 dvc addgit commit 提交视觉变更。
  2. 代码审查: 在 Pull Request 中,审阅者不再需要面对无意义的二进制文件 diff。他们只需要关注 image_snapshots.dvc 文件的变动,这清晰地表明了视觉基准发生了变更。CI 结果会直接报告是否有未预期的视觉差异。
  3. 自动化测试: CircleCI 确保了每个提交到特性分支的代码都与 main 分支的视觉基准进行了严格比对。
  4. 基准更新: 一旦包含视觉更新的 PR 被合并到 main,CI 会自动完成更新基准快照、推送数据到 S3、并提交元数据文件的所有操作,无需任何人工干预。

这个方案的真正价值在于,它将视觉快照从一个难以管理的“负担”转变为一个像代码一样清晰、可控、可追溯的“一等公民”。它为设计系统乃至整个前端应用的视觉质量提供了一道坚实的自动化防线。

局限性与未来迭代方向

尽管该方案解决了核心痛点,但在生产环境中依然存在一些需要权衡和优化的点。

首先是成本问题。S3 存储和数据传输会产生费用。对于大型项目,快照数据量可能达到数十 GB,需要制定合理的生命周期策略来清理旧版本的快照数据,DVC 的 dvc gc 命令可以帮助回收未被任何 git tag/branch 引用的数据。

其次,流水线性能。当组件库非常庞大时,完整构建 Storybook 并运行所有测试可能会耗时较长。一个可行的优化路径是,在 CircleCI 中通过分析 Git diff 来识别被修改的组件,并只针对这些组件及其依赖项运行视觉测试。这需要更复杂的脚本逻辑,但能显著提升流水线效率。

最后,关于“批准”机制。当前的 main 分支工作流是全自动的,这意味着任何合并到 main 的代码都会自动更新基准。在某些对视觉一致性要求极高的团队中,可能需要在 CI 流程中引入一个手动批准步骤(CircleCI Approval Job),由设计或 QA 团队成员在流水线中确认视觉变更后,才执行后续的 dvc pushgit commit 操作。这增加了流程的安全性,但牺牲了一部分自动化程度。


  目录