tech AI generated (Claude)

Playwright ビジュアルリグレッションテストで UI バグの見逃しをゼロにする ─ FlowAgent への VRT 導入事例

toBeVisible() をすり抜けた座標ズレ・描画崩壊バグをきっかけに Playwright toHaveScreenshot() によるビジュアルリグレッションテスト(VRT)を導入した経緯と、8シナリオのテスト設計・CI統合まで解説します。

#Playwright #テスト #ビジュアルリグレッション #React #TypeScript #CI/CD #Next.js

目次


はじめに ─ E2E テストの盲点

toBeVisible() はパスしている。toBeInViewport() も問題ない。なのになぜ UI が壊れているのか」──これは E2E テストを書いたことのあるエンジニアなら一度は直面したことのある状況です。従来の E2E テストは DOM の存在・可視性・テキスト内容 の確認には強い一方で、ピクセル単位の視覚的正確さ を検証する手段を持っていません。

FlowAgent(データ分析の自動化プロダクト)はフロー図を編集する Next.js アプリです。SVG を使ったカスタム描画エンジン、スクロール連動ヘッダー、列凍結(column freeze)など、座標計算が絡む複雑な UI コンポーネントを多数持っています。こうしたコンポーネントは「要素は画面内に存在するし表示もされている、でも数ピクセルずれていたり背景が黒く塗りつぶされたりする」という種類のバグを生みやすい構造です。

本記事では、実装中に発覚した 2 件の視覚的バグを起点に、PlaywrighttoHaveScreenshot() を使ったビジュアルリグレッションテスト(VRT)を導入した経緯を詳しく解説します。設定ファイルの各オプションの選定理由から、8シナリオのテスト設計、CI 統合、運用上のハマりどころまで、実践的な内容を網羅します。


きっかけとなったバグ ─ 既存テストをすり抜けた視覚的破綻

以下に挙げる 2 件のバグは、VRT が検出できるバグの典型例です。SVG やカスタムレイアウトを持つアプリでは、同じ種類の問題が頻繁に発生します。

バグ 1:SVG 座標ズレ

FlowAgent のフロー図エディタは SVG で描画されており、スクロール位置に応じてヘッダーとセルの座標を動的に計算しています。スクロール連動ロジックを変更した後、ヘッダーとセルの座標が数ピクセルずれるというバグが発生しました。

原因は getScreenCTM() の呼び出しタイミングにありました。getScreenCTM() は SVG 要素のスクリーン座標変換行列を返しますが、レンダリングが完了する前に呼び出すと古い値を返します。スクロールイベントのハンドラー内で非同期処理の順序が入れ替わったことで、古い CTM を元に座標が計算され続けていたのです。

既存テストでは page.locator('[data-testid="header-cell-0"]').toBeVisible() のように可視性のみを確認していたため、この数ピクセルのズレは完全に検知できませんでした。「要素は見えているし、クリック可能な位置にある」という条件はすべて満たしていたからです。

バグ 2:CSS レイアウトの描画崩壊

列凍結(column freeze)機能は、横スクロール時に指定した列をスクロールに追従させず固定表示するものです。この機能を実装した後、凍結列より右のノードが真っ黒な背景で表示され、位置も大幅にずれるという重大な描画崩壊が発生しました。

この問題の根本は、凍結列の実装に使用した position: sticky と SVG の overflow: hidden の組み合わせでした。SVG 要素に適用されたスタイルがクリッピング領域を想定外の形で変更し、ノードの描画が背景レイヤーの外に出てしまっていました。

こちらも既存の E2E テストは「ノードが存在すること」「ノードのテキストが正しいこと」しか確認していなかったため、描画の崩壊は検出されませんでした。expect(node).toHaveText('Step 1') は真っ黒な背景の上に描画されているノードに対してもパスしてしまうのです。

なぜ既存テストをすり抜けたか

この 2 件のバグが浮き彫りにしたのは、機能テストと視覚テストの根本的な違いです。機能テストは「何が起きるか」を検証します。「クリックすると遷移する」「テキストが表示される」といった振る舞いです。しかし UI の視覚的正確さは「どのように見えるか」の問題であり、ピクセル座標や色の組み合わせは機能テストでは表現できません。

座標ズレや描画崩壊を機能テストで検出しようとすると、getBoundingClientRect() の戻り値を細かく検証する複雑なアサーションが必要になります。しかしその閾値の設定や、レイアウトエンジンのわずかな差による flaky 化など、保守コストが非常に高くなります。VRT はスクリーンショットという「最終的な見た目」をそのまま比較するため、こうした問題をシンプルに解決できます。


Playwright VRT の仕組み ─ ゴールデンスクリーンショット方式

toHaveScreenshot() とは

Playwright には expect(page).toHaveScreenshot() というアサーションが組み込まれています。このメソッドはページまたは特定のロケーターのスクリーンショットを撮影し、ゴールデンスクリーンショット(基準画像)と比較します。差分がしきい値を超えると、テストが失敗します。

// ページ全体を比較
await expect(page).toHaveScreenshot("editor-initial.png");

// 特定の要素だけを比較
await expect(page.locator('[data-testid="swimlane-container"]'))
  .toHaveScreenshot("swimlane.png");

ゴールデンスクリーンショットは e2e/__screenshots__/ ディレクトリ(設定によって変わる)に保存されます。このファイルは Git でバージョン管理することで、「どの時点の見た目が正」であるかをコードと一緒に追跡できます。

初回実行でベースラインを作る

VRT を初めて導入するときは、比較対象となるゴールデンスクリーンショットがまだ存在しません。Playwright は --update-snapshots フラグで「スクリーンショットを新規作成または更新する」モードで動作します。

# ゴールデンスクリーンショットを新規作成(初回)
npx playwright test e2e/visual-regression.spec.ts --update-snapshots

# 通常のテスト実行(ゴールデンと比較)
npx playwright test e2e/visual-regression.spec.ts

初回実行後、e2e/__screenshots__/.png ファイルが生成されます。このファイルをレビューして「これが正しい見た目だ」と確認してから Git にコミットします。以降の PR でこのゴールデンと比較し続けることで、視覚的なリグレッションを検出できます。

意図的な UI 変更を加えた場合(デザイン更新など)は、再び --update-snapshots を実行してゴールデンを更新し、新しい見た目をコミットします。

差分の検出と閾値

Playwright の VRT は「ピクセル完全一致」を要求するわけではありません。サブピクセルレンダリングの差異やアンチエイリアシングなど、環境間でわずかな差が生じることは避けられないからです。maxDiffPixelsthreshold という 2 つのパラメータで許容範囲を設定できます。

パラメータ意味推奨設定
maxDiffPixels差分として検出するピクセルの最大数50〜100
threshold1ピクセルあたりの許容色差(0.0〜1.0)0.1〜0.2
maxDiffPixelRatio全ピクセルに対する差分率0.01〜0.02

maxDiffPixelsthreshold は AND 条件ではなく、どちらかの条件を超えると失敗します。小さな座標ズレ(数ピクセル)は複数のピクセルに色差として現れるため、maxDiffPixels: 50 程度に設定しておけば十分に検出できます。


playwright.config.ts の VRT 設定

FlowAgent では以下の playwright.config.ts を使用しています。各設定の選定理由を詳しく解説します。

import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./e2e",
  fullyParallel: false, // VRTはシリアル実行(フォント・レイアウト競合を防ぐ)
  retries: process.env.CI ? 1 : 0,
  use: {
    baseURL: "http://localhost:3000",
    // ロケールとタイムゾーンを固定(スクリーンショット再現性のため)
    locale: "ja-JP",
    timezoneId: "Asia/Tokyo",
    // VRTのデフォルト設定
    screenshot: "only-on-failure",
  },
  expect: {
    toHaveScreenshot: {
      maxDiffPixels: 50,       // 50ピクセルまでの差は許容
      threshold: 0.1,          // ピクセルあたり10%以内の色差は許容
      animations: "disabled",  // アニメーション無効化(再現性向上)
    },
  },
  webServer: {
    command: "npm run dev",
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
  ],
});

fullyParallel: false ─ シリアル実行の理由

VRT において並列実行は flaky テストの温床になります。複数のテストが同時に動作すると、フォントのレンダリングキャッシュやレイアウトエンジンの状態が競合し、同じコードでも毎回わずかに異なるスクリーンショットが生成されることがあります。FlowAgent の SVG 描画は特に CPU 負荷が高く、並列実行によるタイミングのズレが座標計算に影響することも確認しています。

シリアル実行にすることでテスト時間は伸びますが、VRT の再現性と信頼性が大幅に向上します。一般的な VRT スイートでは並列実行によるスピードアップよりも、スクリーンショットの一貫性の方が重要です。

locale と timezoneId の固定

locale: "ja-JP"timezoneId: "Asia/Tokyo" を固定しているのは、日付・時刻・数値の表示形式がスクリーンショットに含まれる場合の差異を防ぐためです。ダッシュボードに現在時刻や数値グラフが表示される場合、ロケールが変わると表示が変わり、ゴールデンとの差分が生じます。

フォントレンダリングもロケールの影響を受けることがあります。日本語フォントの有無や、OS のサブピクセルレンダリング設定によって、同じ文字列でも微妙に描画が変わります。CI 環境(Ubuntu)とローカル環境(macOS)ではフォントセットが異なるため、後述するように ゴールデンスクリーンショットは CI 環境で生成することを推奨します。

animations: “disabled” ─ アニメーション停止の重要性

CSS トランジションやアニメーションが実行中にスクリーンショットを撮影すると、アニメーションの「途中の状態」が記録されます。次のテスト実行でアニメーションのタイミングがわずかにずれると、スクリーンショットの差分が生じて false positive(誤った失敗)が発生します。

animations: "disabled" を設定すると、Playwright はスクリーンショット撮影前にすべての CSS アニメーションとトランジションを停止します。これにより、アニメーション完了後の「最終状態」のみが記録され、再現性が大幅に向上します。

retries: process.env.CI ? 1 : 0

CI 環境では 1 回のリトライを許容しています。VRT は環境の微妙な違いにより、稀に false positive を出すことがあります。1 回のリトライにより、一時的なレンダリングの揺れが原因の偽陽性を排除できます。ただし 2 回連続で失敗した場合は本物のリグレッションと判断します。ローカルではリトライなし(0)にして、テストの問題をすぐに把握できるようにしています。


8シナリオの設計思想

VRT のシナリオは「視覚的に壊れやすい操作」を起点に選定しました。以下にシナリオ一覧と、そのシナリオを選んだ理由を示します。

#シナリオ名テストする状態選定理由
1エディタ初期表示空のフローベースライン。何もない状態のレイアウト崩れを検出
2大規模フロー読み込み後31ノード/31エッジ多数ノードでの SVG レンダリング確認
3スクロール後のヘッダー位置横スクロール 400px座標ズレバグの再発防止(バグ 1 の直接対策)
4列凍結後の描画最初の列を凍結描画崩壊バグの再発防止(バグ 2 の直接対策)
5ズームイン後Ctrl+Wheel でズームズーム時の座標変換・テキストスケールの確認
6タッチパッドパン後jsEvent でパン操作SVG ビューポートのオフセット計算確認
7ダッシュボード初期表示/ ページメインページのレイアウト回帰検出
8ダッシュボード大規模データ多数データ表示データ量によるレイアウト崩れ(折り返し等)の確認

シナリオ選定の基準

「座標計算を伴う操作」を優先するというのが FlowAgent での基本方針です。SVG の描画はスクロール位置・ズームレベル・凍結列の有無などで座標変換が複数回行われます。これらが絡み合う操作の後に視覚的な崩壊が起きやすいため、各種変換操作を組み合わせたシナリオを設計しました。

バグ発生箇所を必ずシナリオ化するという方針も採っています。バグ 1(SVG 座標ズレ)に対してシナリオ 3(スクロール後のヘッダー確認)、バグ 2(凍結ペイン崩壊)に対してシナリオ 4(列凍結後の描画)が直接対応しています。「発見されたバグをテストで固定する」というリグレッション防止の王道アプローチです。

「初期表示」は必ず含めることもルール化しました。シナリオ 1 と 7 がこれに当たります。初期表示の崩れは最も基本的なリグレッションであり、他の操作シナリオが通過していても初期表示が壊れていては意味がありません。


テスト実装の詳解

visual-regression.spec.ts の全体構造

import { test, expect } from "@playwright/test";
import { setupLargeFlow } from "./helpers/large-flow-setup";

test.describe("Visual Regression Tests", () => {
  test.beforeEach(async ({ page }) => {
    await page.goto("/editor");
    await page.waitForLoadState("networkidle");
  });

  test("エディタ初期表示", async ({ page }) => {
    await expect(page).toHaveScreenshot("editor-initial.png");
  });

  test("大規模フロー読み込み後", async ({ page }) => {
    await setupLargeFlow(page, { nodes: 31, edges: 31 });
    await page.waitForTimeout(500); // SVGレンダリング安定待ち
    await expect(page).toHaveScreenshot("editor-large-flow.png");
  });

  test("列凍結後の描画", async ({ page }) => {
    await setupLargeFlow(page, { nodes: 31, edges: 31 });
    // 最初の列を凍結
    await page.locator('[data-testid="freeze-column-0"]').click();
    await page.waitForTimeout(300);
    await expect(page).toHaveScreenshot("editor-column-freeze.png");
  });

  test("スクロール後のヘッダー位置", async ({ page }) => {
    await setupLargeFlow(page, { nodes: 31, edges: 31 });
    // 右にスクロール
    const canvas = page.locator('[data-testid="swimlane-canvas"]');
    await canvas.evaluate((el) => { el.scrollLeft = 400; });
    await page.waitForTimeout(200);
    await expect(page.locator('[data-testid="swimlane-container"]'))
      .toHaveScreenshot("editor-scrolled.png");
  });

  test("ズームイン後", async ({ page }) => {
    await setupLargeFlow(page, { nodes: 10, edges: 8 });
    // Ctrl+Wheel でズームイン
    const canvas = page.locator('[data-testid="swimlane-canvas"]');
    await canvas.dispatchEvent("wheel", { deltaY: -100, ctrlKey: true });
    await page.waitForTimeout(300);
    await expect(page).toHaveScreenshot("editor-zoom-in.png");
  });
});

test.describe("Dashboard VRT", () => {
  test("ダッシュボード初期表示", async ({ page }) => {
    await page.goto("/");
    await page.waitForLoadState("networkidle");
    await expect(page.locator('[data-testid="dashboard"]'))
      .toHaveScreenshot("dashboard-initial.png");
  });
});

beforeEach での networkidle 待機

waitForLoadState("networkidle") はネットワークリクエストが 500ms 以上発生しない状態になるまで待機します。フロー図エディタは初期表示時にフローデータを API から取得するため、データ取得完了前にスクリーンショットを撮ると「ローディング中の画面」が記録されてしまいます。networkidle 待機でデータ取得とレンダリングが完了してからスクリーンショットを撮ることを保証しています。

page.waitForTimeout の使い方

waitForTimeout は一般的には避けるべき実装です(任意の時間待機は flaky の原因になりやすい)。しかし SVG レンダリングやアニメーション完了の検出は、waitForSelector では捉えにくい場面があります。

FlowAgent では以下の指針で waitForTimeout を使用しています。

  • 500ms: 31ノードの SVG 全描画が完了するまでの経験的な待機時間
  • 300ms: クリック操作後の状態変更アニメーション完了待ち
  • 200ms: スクロールイベント後の同期的な座標更新待ち

将来的には waitForFunction を使って描画完了フラグを検出する方法に移行することを検討していますが、現時点ではこの値で安定動作しています。

large-flow-setup.ts ヘルパー

テストデータのセットアップロジックを helpers/ ディレクトリに切り出すことで、各テストファイルをシンプルに保ちます。

// e2e/helpers/large-flow-setup.ts
import { Page } from "@playwright/test";

interface SetupOptions {
  nodes: number;
  edges: number;
}

export async function setupLargeFlow(page: Page, options: SetupOptions): Promise<void> {
  const { nodes, edges } = options;

  // テスト用フローデータをAPIでセット
  await page.evaluate(({ n, e }) => {
    // window.__TEST_FLOW__ を通じてフローデータを注入
    (window as any).__TEST_FLOW__ = generateTestFlow(n, e);
  }, { n: nodes, e: edges });

  // フロー読み込みボタンをクリック
  await page.locator('[data-testid="load-test-flow"]').click();
  await page.waitForSelector('[data-testid="flow-loaded"]', { state: "visible" });
}

function generateTestFlow(nodeCount: number, edgeCount: number) {
  return {
    nodes: Array.from({ length: nodeCount }, (_, i) => ({
      id: `node-${i}`,
      type: "process",
      label: `Step ${i + 1}`,
      row: Math.floor(i / 5),
      col: i % 5,
    })),
    edges: Array.from({ length: edgeCount }, (_, i) => ({
      id: `edge-${i}`,
      source: `node-${i}`,
      target: `node-${i + 1}`,
    })),
  };
}

page.evaluate() でブラウザコンテキストにテストデータを注入し、アプリ側で window.__TEST_FLOW__ を参照してフローを読み込む設計にしています。これにより、テスト専用の API エンドポイントを用意しなくてもテストデータを柔軟に制御できます。

アプリ側には data-testid="load-test-flow" というボタンと data-testid="flow-loaded" という読み込み完了マーカーを process.env.NODE_ENV === "test" の条件で表示しており、本番ビルドには含まれません。

タッチパッドパン操作のシミュレート

タッチパッドのパン操作は wheel イベントに ctrlKey: false で発火します。jsEvent を使ってシミュレートします。

test("タッチパッドパン後", async ({ page }) => {
  await setupLargeFlow(page, { nodes: 20, edges: 15 });
  const canvas = page.locator('[data-testid="swimlane-canvas"]');

  // タッチパッドの横スクロール(X軸方向のパン)
  await canvas.dispatchEvent("wheel", {
    deltaX: 200,
    deltaY: 0,
    ctrlKey: false,
    bubbles: true,
  });

  // 縦スクロール
  await canvas.dispatchEvent("wheel", {
    deltaX: 0,
    deltaY: 150,
    ctrlKey: false,
    bubbles: true,
  });

  await page.waitForTimeout(300);
  await expect(page).toHaveScreenshot("editor-pan-after.png");
});

ctrlKey: truewheel イベントはズーム操作として解釈されるのに対し、ctrlKey: falsewheel イベントはパン(スクロール)として扱われます。この違いを利用してズームとパンを独立してテストしています。


CI ワークフロー設計

e2e-visual.yml の全体構成

name: E2E Visual Regression

on:
  pull_request:
    paths:
      - "src/**"
      - "e2e/**"
      - "playwright.config.ts"

jobs:
  vrt:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps chromium

      - name: Run VRT
        run: npx playwright test e2e/visual-regression.spec.ts
        env:
          CI: true

      - name: Upload screenshots on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-screenshots
          path: |
            e2e/**/*.png
            test-results/**
          retention-days: 7

paths フィルタによるトリガー最適化

paths フィルタで src/**e2e/**playwright.config.ts の変更時のみ VRT を実行しています。ドキュメントのみの変更(docs/**)や設定ファイルの軽微な変更で毎回 VRT が走ると、CI の待機時間が増えてデベロッパー体験が低下します。

VRT は機能テストと比べてテスト時間が長くなりがちです。FlowAgent の 8 シナリオは約 3 分かかります。パス絞り込みにより、UI に影響しない変更では VRT がスキップされ、無駄な待機を排除できています。

スクリーンショットを artifact で保存

テスト失敗時に actions/upload-artifact でスクリーンショットを保存しています。これにより、CI 上でどのように画面が見えたかを GitHub Actions の artifact からダウンロードして確認できます。

Playwright はテスト失敗時に自動的に test-results/ ディレクトリを作成し、ゴールデン画像・実際のスクリーンショット・差分画像の 3 点セットを保存します。差分画像は赤くハイライトされた部分が視覚的に確認できるため、どのピクセルが変わったかを一目で把握できます。

test-results/
└── vrt-editor-column-freeze/
    ├── editor-column-freeze-expected.png  # ゴールデン
    ├── editor-column-freeze-actual.png    # 実際の結果
    └── editor-column-freeze-diff.png      # 差分(赤ハイライト)

retention-days: 7 で 7 日間保持しています。長期保存は不要ですが、PR レビュー期間中は確認できるようにしておく必要があります。

ゴールデンスクリーンショットの管理戦略

ゴールデンスクリーンショットは Git にコミットして管理します。以下の .gitignore 設定で、ゴールデンは含め、テスト実行時の一時ファイルは除外します。

# Playwright テスト成果物(除外)
test-results/
playwright-report/

# ゴールデンスクリーンショット(含める ─ コメントで明示)
# e2e/**/*.png は .gitignore に含めない

意図的な UI 変更を行った場合のゴールデン更新手順を docs/specs/flowagent-vrt-design.md にドキュメント化しています。

# ゴールデンを更新する手順
# 1. ローカルで変更が意図的であることを確認
# 2. CI 環境と同じ条件でゴールデンを生成(Docker 推奨)
npx playwright test e2e/visual-regression.spec.ts --update-snapshots

# 3. 生成されたゴールデンをレビュー
# 4. コミット
git add e2e/__screenshots__/
git commit -m "test(vrt): update golden screenshots for column-freeze redesign"

VRT 運用上の注意点

フォントレンダリング差異(OS 依存)

VRT で最も多くの問題を引き起こすのがフォントレンダリングの差異です。macOS と Linux(Ubuntu)ではフォントのサブピクセルレンダリングアルゴリズムが異なり、同じフォント・同じサイズでもピクセル単位では微妙に異なって描画されます。

この問題への対策として、ゴールデンスクリーンショットは必ず CI 環境(Ubuntu)で生成するという方針を採用しています。ローカル(macOS)で --update-snapshots を実行してコミットすると、Ubuntu で実行される CI でフォント差異による false positive が多発します。

Docker を使ってローカルでも Ubuntu 環境でゴールデンを生成する方法もあります。

# Docker で Ubuntu 環境のゴールデンを生成
docker run --rm \
  -v $(pwd):/work \
  -w /work \
  mcr.microsoft.com/playwright:v1.42.0-jammy \
  npx playwright test e2e/visual-regression.spec.ts --update-snapshots

この方法を採れば、CI との環境差異を完全に排除できます。ただしセットアップコストがあるため、フォント差異が実際に問題になるまでは CI での生成・更新で運用するのが現実的です。

アニメーション停止で解決できないケース

animations: "disabled" で CSS アニメーションは停止できますが、JavaScript で制御されるアニメーション(requestAnimationFrame ベースのものなど)は停止できません。FlowAgent の SVG ノードドラッグ操作後の「スナップアニメーション」がこれに該当します。

対策として、テストコード側でアニメーション完了を待機するか、テスト専用のフラグでアニメーションを無効化します。

// アプリ側に PLAYWRIGHT_DISABLE_ANIMATIONS フラグを設定
await page.addInitScript(() => {
  (window as any).__PLAYWRIGHT_MODE__ = true;
});

アプリのコードでこのフラグを参照し、テスト中はアニメーションをスキップするように実装します。

flaky テストへの対処

VRT において flaky(不安定)なテストが発生した場合の対処手順を以下に示します。

症状原因候補対処
毎回わずかに差分が出るアニメーション未停止animations: "disabled" 確認、JS アニメーションを無効化
ローカルでは通るが CI で失敗フォントレンダリング差異CI 環境でゴールデンを再生成
特定の順序でのみ失敗テスト間の状態汚染beforeEach でのリセット漏れを確認
稀にランダム差分が出る非同期レンダリングのタイミングwaitForTimeout を増やす、または描画完了フラグを使う
ズームやスクロール後にずれるwaitForTimeout が短い待機時間を段階的に増やして安定値を確認

flaky テストは「再試行で通ることがある」性質のため、CI の retries: 1 で一定数は吸収できます。しかし根本的には原因を特定して修正することが重要です。retries はあくまで一時的な緩衝材として使います。

maxDiffPixels の調整

初回導入時は maxDiffPixels: 50 から始めて、実際のテスト結果を見ながら調整します。あまりに大きく設定すると本物のバグを見逃す恐れがありますが、小さすぎると環境差異による false positive が増えます。

FlowAgent では以下の経験値から 50 ピクセルを採用しました。

  • SVG のアンチエイリアシングによる差異: 最大 10〜15 ピクセル
  • フォントのヒンティング差異: 最大 5〜20 ピクセル
  • 合計でも 50 ピクセルは超えない一方、2〜3px の座標ズレは 50 ピクセル以上の差分として検出される

実際に VRT で検出できたこと

導入後に防いだリグレッション

VRT 導入から数週間の間に、以下の視覚的リグレッションを CI で自動検出できました。

ケース 1: スクロール同期ロジックのリファクタリング後の座標ズレ ヘッダーのスクロール同期コードをパフォーマンス改善のためにリファクタリングした際、シナリオ 3(スクロール後のヘッダー位置)が差分を検出しました。差分は 3〜5ピクセルのヘッダーオフセットで、機能テストでは全て通過していました。

ケース 2: Tailwind CSS アップグレードによるレイアウト変動 Tailwind CSS のマイナーバージョンアップで、gap プロパティのデフォルト値が変更されました。これによりダッシュボードのカードレイアウトが数ピクセル変化し、シナリオ 7(ダッシュボード初期表示)が検出しました。意図しない変更であったため、ロールバックして対処できました。

ケース 3: z-index 変更による重なり順の崩壊 モーダルコンポーネントの z-index を調整した際、凍結列ヘッダーの z-index との競合が生じ、凍結列の一部がモーダルの背後に隠れるバグが発生しました。シナリオ 4(列凍結後の描画)がこれを検出しました。

これら 3 ケースはいずれも機能テストでは検出不可能でした。「要素は存在する・クリック可能・テキストが正しい」という条件はすべて満たしながら、見た目が壊れていたからです。

VRT 導入前後の比較

指標導入前導入後
視覚的バグの検出タイミングユーザー報告 or 手動確認PR 時に自動検出
座標ズレの検出不可(機能テストでは無視)50px 以上の差分を自動検出
描画崩壊の検出不可ゴールデン比較で即時検出
テスト追加コスト低(機能テストは軽量)中(ゴールデン管理が必要)
CI 時間の増加0分+3分

CI 時間が 3 分増えるトレードオフはありますが、視覚的バグをユーザーに届く前に検出できるというメリットははるかに大きいと評価しています。


まとめ

Playwright の toHaveScreenshot() を使ったビジュアルリグレッションテストは、機能テストの盲点を埋める強力な手段です。FlowAgent での導入を通じて得られた知見をまとめます。

設定のポイント

  • fullyParallel: false でシリアル実行し、スクリーンショットの再現性を確保する
  • localetimezoneId を固定し、表示形式の差異を排除する
  • animations: "disabled" でアニメーション停止後の状態を記録する
  • maxDiffPixels: 50 はフォントアンチエイリアシングを許容しつつ座標ズレを検出できる適切な値

シナリオ設計のポイント

  • 発生したバグを起点にシナリオを作ることでリグレッション防止の網を張る
  • 座標変換を伴う操作(スクロール・ズーム・凍結)は優先的にシナリオ化する
  • 初期表示は最低限として必ず含める

運用のポイント

  • ゴールデンスクリーンショットは CI 環境(Linux)で生成することでフォント差異問題を回避する
  • retries: 1 で偶発的な flaky を吸収しつつ、根本原因の修正を怠らない
  • 意図的な UI 変更時のゴールデン更新手順をドキュメント化して属人化を防ぐ

E2E テストに VRT を加えることで、「動作するが壊れて見える」というクラスのバグを CI の段階で自動検出できるようになります。特に SVG・Canvas・複雑なレイアウトを持つアプリでは、VRT の効果が際立ちます。まずは最も崩れやすい 2〜3 シナリオから始め、バグが発見されるたびにシナリオを追加していくアプローチが導入コストを抑えつつ着実にカバレッジを高める方法としておすすめです。

関連記事