🐺

VRTを回帰テストとして再認識したらNoコストかつ差分閾値0から始められる世界が待ってた話

に公開

TL;DR

  • 絶対に VRT をオオカミ少年にしないという鉄の意志で実行構成を再定義してみたよ
  • No コストな構成なので運用してみたけどまあ特筆した辛みは無かったからまとめてみたよ

1. VRT とは、そしてよくある課題

1.1. VRT の基本

VRT(Visual Regression Testing)は端的に言うと
「事前に用意したレンダリング結果(正解画像)」と「改修後のレンダリング結果」をスクリーンショットで比較し、意図しない視覚的変更を検出する テスト手法です。

例えば Button コンポーネントに変更を加えた際、その Button を使用している他のすべてのコンポーネントに意図しない影響が出ていないかの確認が自動かつ目視より正確に行えるため、特に UI コンポーネントライブラリでは重宝されやすいかなと思います。

1.2. あるあるの実行構成

VRT を実際に運用する際、ローカル開発環境だけでなく CI 環境でも実行できるようにするため、正解画像を共有する仕組みが必要になります。

一般的なアプローチは以下の 2 つが挙げられる(気がします):

  1. Git のバージョン管理に含める
    • 正解画像を.pngファイルとしてリポジトリにコミット
    • ローカル(開発者間)、CI の両方で手軽に正解画像を共有
  2. S3 などの外部ストレージに保存
    • 正解画像をクラウドストレージにアップロード
    • ローカル(開発者間)、CI の両方でダウンロードして共有

1.3. あるあるの課題

しかし、VRT を実際に運用に乗せると以下のような課題に直面するのは想像に容易いと思います。

  • ピクセル単位の比較なため、OS が正解画像を作成した側と VRT 実行側で異なると、改修とは無縁の部分で差分が誤検知される
  • 例)macOS で正解画像を生成 → Linux で実行すると、フォントレンダリングの微妙な差異で差分検出
  • 改修していないコンポーネントでも VRT が失敗 → VRT がオオカミ少年化

この対策として、VRT 実行用の Docker ファイルを用意し、正解画像作成と実行時の環境を統一するアプローチがよく採られるかな、と思います。
筆者もこの努力を経験しましたが、どこまで行っても結局本質的でない課題と付き合い続け辛酸を舐めてきました。

結果として、以下のような運用に陥りやすい:

  • 閾値(threshold)の調整を繰り返す
  • ある程度オオカミ少年として付き合う(失敗レポートを目視で確認し、0.xx%の誤差なら見逃すなど)

そして、本音。

「テスト 1 つ導入するのに外部ストレージ連携とか Docker のセットアップとかして、
その先に得られるものが課題感と付き合いながらの運用って、
なんだかなぁ...まぁ必要だからやるかぁ...なんだかなぁ...」

2. VRT を Regression Test として再認識する

2.1. 問題の本質

従来のアプローチが行き詰まる理由は、VRT を 「共有のあるべき UI との差分」 として運用していることにあるかな、と推察します。

  • Git 管理や外部ストレージでの正解画像の共有
  • Docker での環境統一

2.2. VRT の本質とはあくまで Regression

しかし、VRT の本質とはなんでしょうか?
それは 「改修によって意図しない視覚的変更が発生していないか」を検出することにあると思います。

つまり、VRT は Regression Test(回帰テスト)であり、求められるのは「絶対値としてのあるべき UI」ではなく、「改修によって発生する差分」 です。

2.5. 回帰を主題にした実行構成を設計する

ここまで仰々しく書きましたが、実際の方針は至ってシンプルです。

正解画像を 自身の環境の作業前ブランチ で準備し、それを Git のバージョン管理に含めずにテスト実行ブランチに受け渡す ことです。
この主語である"自身"の部分はローカル(開発者間)、CI 問わずです。
非常にシンプル。シンプル過ぎて記事にするかも迷った。

  • 実行環境ごとに正解画像を作業前のブランチで生成する
  • 各開発者のローカル環境は、独立した正解画像を持つ(.gitignoreで除外)
  • CI 環境の正解画像は、GitHub Artifacts で管理・共有

3. 実行フロー

実際の実行フローを簡略化して挙げていきます。詳細な実装は 5 節に記載しているので必要であればご確認ください 🙇

3.1. ローカル実行フロー

VRT 自体は Playwright1 つあれば実行可能で、正解画像を config でどこに配置するか定義しておけば.toHaveScreenshotアサーションを使用するだけです。
開発者がローカルで VRT を実行する際のフローは以下の通り:

# 1. developブランチで正解画像を生成(初回のみ)
git checkout develop
pnpm vrt:generate-expected

# 正解画像が expected/ に保存される
# .gitignoreされているため、Gitには含まれない
# 2. 作業ブランチで改修を行う
git checkout feature/update-button
# コンポーネントを修正...
# 3. VRT実行(自分の環境の正解画像と比較)
pnpm vrt:run

# 内部で実行される処理:
# - expected/ の正解画像と比較
# - 差分があれば actual/ と diff/ に画像を保存

3.2. CI 実行フロー

CI 環境では、2 つの GitHub Actions ワークフローが連携して動作します。

workflow1: 正解画像生成(vrt-generate-expected.yml)

トリガー: vrt:generate-expected ラベル付きPRがdevelopにマージされた時 or 手動実行

実行内容:
  1. developブランチにcheckout(手動実行時はinputされたブランチ名を使用)し、 pnpm vrt:generate-expected

  2. 既存のアーティファクトを削除(GitHub API使用)
     # アーティファクトが多数残り続けないようにするため、また、正解画像の参照先を常に”最新のアーティファクト”という明確な仕様にするため

  3. 正解画像をアーティファクトにアップロード

workflow2: VRT テスト実行(vrt-test.yml)

トリガー: PRに vrt:run ラベルが追加された時

実行内容:
  1. 直近のアーティファクトから正解画像を expected/ にダウンロード(GitHub API使用)
     # 別ワークフロー実行で生成されたアーティファクトをkey指定で取得。

  2. VRT実行

4. 運用してみた Pros/Cons

4.1. Pros

  • 基本的なコンポーネントはすべて threshold: 0 運用が可能になる。VRT の本質的な”作業による Regression の確認”に集中できる、オオカミ少年にならない。
  • 必要なものは Github Actions だけなので、No 追加コスト&ミニマムですぐにスタートが可能
  • 正解画像の保存ブランチを適宜変更すれば、「作業ブランチから派生した作業ブランチ」での Regression Test も問題なく対応可能
  • 正解画像の更新、VRT の実行、それぞれを PR のラベルで運用することで、CI 実行時間を不必要に伸ばさずに運用可能(ラベルつけ忘れも手動実行で対応可能)

4.2. Cons

  • 良くも悪くも Git/GitHub におんぶにだっこな独自構成のため、コンテキストが複雑に見える
  • アーティファクト を 30 日間保存にしているので、期間内に develop に VRT 更新 PR マージが 1 度もないと手動で再生成必須になる
  • 正解画像を保存したアーティファクトが常に 1 つになるようにしたため、同時に開発者が複数の PR で VRT を実行したい&作業ブランチを正解としたい、など込み入ったケースでコミュニケーションが必要になる
  • Loglass はある程度成熟したコンポーネントライブラリとなっており、改修頻度も低く、改修範囲も軽微なケースが多い。この環境下ではない状況で運用した際にまだ見ぬ Cons が出る可能性はある
  • ある程度の shell 芸を許容する必要がある

5. 参考実装

本記事で紹介した実装の詳細は、一部抜粋で記載しておきます。

VRT 実行テストファイルサンプル
import { expect, test } from '@playwright/test';

# 正解画像の配置場所をconfigで指定しているので対象の画像ファイル名を指定するだけのシンプルなテストコード
test.describe('Button Component VRT', () => {
  test.beforeEach(async ({ page }) => {
    // CSS読み込み待機
    await page.waitForLoadState('networkidle');
  });

  test('button', async ({ page }) => {
    await page.goto('/iframe.html?id=button&viewMode=story');
    await page.locator('#storybook-root').waitFor(); // コンテンツ読み込み完了待機

    await expect(page).toHaveScreenshot('button.png');
  });
});
正解画像生成ワークフロー
name: VRT Expected Generation

on:
  pull_request:
    types: [closed, opened, synchronize, labeled]
    branches:
      - develop
  workflow_dispatch:
    inputs:
      branch:
        description: "正解画像を生成するブランチを指定してください"
        required: true
        default: "develop"
        type: string

jobs:
  generate-expected:
    if: |
      (github.event_name == 'workflow_dispatch') ||
      (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'vrt:generate-expected'))
    runs-on: ubuntu-latest
    steps:
      # ...諸々の実行準備

      - name: 正解画像を生成
        id: vrt-generate
        run: |
          pnpm vrt:generate-expected

      # 既存のアーティファクトを削除
      # upload-artifact@v4のoverwriteパラメータは同一ワークフロー実行内でのみ機能し、
      # 異なるワークフロー実行間では機能しないため、明示的に既存のアーティファクトを削除する
      - name: 既存のアーティファクトを削除
        uses: actions/github-script@vx.x.x
        with:
          script: |
            // 同名のアーティファクトを検索
            const artifacts = await github.rest.actions.listArtifactsForRepo({
              owner: context.repo.owner,
              repo: context.repo.repo,
              name: 'vrt-expected-images'
            });

            // 既存のアーティファクトをすべて削除
            for (const artifact of artifacts.data.artifacts) {
              console.log(`Deleting artifact: ${artifact.name} (ID: ${artifact.id})`);
              await github.rest.actions.deleteArtifact({
                owner: context.repo.owner,
                repo: context.repo.repo,
                artifact_id: artifact.id
              });
            }

      - name: 正解画像をアーティファクトとしてアップロード
        uses: actions/upload-artifact@vx.x.x
        with:
          name: vrt-expected-images
          # 正解画像保存ディレクトリパス
          path: expected/
          retention-days: 30
VRT テスト実行時の正解画像ダウンロードワークフロー
# 別のワークフロー実行で生成されたアーティファクトはactions/download-artifactでは取得できないため、
# GitHub APIを使用してダウンロードする
- name: 正解画像をアーティファクトからダウンロード
  uses: actions/github-script@vx.x.x
  with:
    script: |
      const fs = require('fs');
      const { execSync } = require('child_process');

      // 最新のvrt-expected-imagesアーティファクトを取得
      const artifacts = await github.rest.actions.listArtifactsForRepo({
        owner: context.repo.owner,
        repo: context.repo.repo,
        name: 'vrt-expected-images'
      });

      if (artifacts.data.artifacts.length === 0) {
        throw new Error('vrt-expected-imagesアーティファクトが見つかりません');
      }

      // 最新のアーティファクトを取得
      const latestArtifact = artifacts.data.artifacts[0];

      // アーティファクトをダウンロード
      const download = await github.rest.actions.downloadArtifact({
        owner: context.repo.owner,
        repo: context.repo.repo,
        artifact_id: latestArtifact.id,
        archive_format: 'zip'
      });

      // 展開先ディレクトリを作成
      execSync('mkdir -p expected');

      // ZIPファイルを直接展開
      fs.writeFileSync('/tmp/vrt-expected.zip', Buffer.from(download.data));
      execSync('unzip -o /tmp/vrt-expected.zip -d expected/');

      console.log('正解画像のダウンロード完了');

6. さいごに

VRT を回帰テストとして再認識したときに「あるべき UI の絶対値を共有」がどうしてもネックに感じたのでやってみたら意外と形になった実行構成だったので紹介でした。

オオカミ少年にしない鉄の意志。

We Are Hiring!

ログラスには事業のスケールに合わせてフロントエンド開発基盤をスケールさせるために、
基盤開発チーム内にフロントエンド専任のチームが存在しています。
今回の活動はその一端に過ぎず、裁量広く常にフロントエンドのコアな領域への挑戦を続けております!
ご興味持たれた方はぜひお話しましょう!
https://hrmos.co/pages/loglass/jobs/Eng-FE-202509

Discussion