ビジュアルリグレッションテストによるUIバグの検出
PlaywrightとVitestのビジュアル回帰テストで、単体テストやE2Eでは見逃すUIバグを検出。baseline管理、flakiness対策、CI設定も解説。
ビジュアルリグレッションテストは、コンポーネントやページを制御されたブラウザ環境でレンダリングし、スクリーンショットを撮影して、承認済みのベースライン画像とピクセル単位で比較することで、意図しないUI変更を検出します。設定可能なしきい値を超える差分が検出されると、テストが失敗し、人間によるレビューのために変更が通知されます。このアプローチは、ユニットテストやエンドツーエンドテストが構造的に検出できないバグのクラスを捕捉します。たとえば、クリックハンドラーは正常に動作しているのに、ボタンの色が間違っていたり、12ピクセルずれていたり、モーダルオーバーレイの背後に隠れてしまっているといったケースです。
これは、CSSリファクタリングで発生し、本番環境のレイアウトを静かに壊してしまう典型的な障害パターンです。テストはすべてパスし、ビルドはグリーンなのに、ユーザーがチェックアウトボタンがクッキーバナーに隠れていることを発見する——そういった事態です。本記事では、有料サービスを使わずにフレームワークネイティブのツール(Playwrightの組み込みスクリーンショットアサーションとVitestのブラウザモードによるビジュアルテスト)でこのギャップを埋める方法を解説し、実用的なテストスイートと単なるノイズを生み出すだけのスイートを分ける、フレーキーネス制御とCI設定についても説明します。
重要なポイント
- ビジュアルリグレッションテストは、レンダリングされたスクリーンショットを承認済みのベースラインと比較します。設定可能なしきい値を超える差分はテスト失敗となり、機能テストではパスしてしまう色・位置・スタッキングコンテキストのバグを検出します。
- Playwrightの
expect(page).toHaveScreenshot()は組み込みかつ無料で、animationsのデフォルトがすでに'disabled'に設定されており、2回連続してスクリーンショットが一致するまで自動リトライします。そのためwaitForTimeoutは不要です。 - フレーキーネスの原因のほとんどは4つに絞られます。CSSアニメーション、遅延読み込みのウェブフォント、動的コンテンツ、GPUやOSによって異なるサブピクセルアンチエイリアシングです。それぞれに具体的な対処法があり、しきい値を緩めることが解決策ではありません。
- フォントレンダリングはOSによって異なり、誤検知を生む可能性があるため、CIでは特定のPlaywright Dockerイメージ(
mcr.microsoft.com/playwright:v1.61.0-noble)に固定してください。 - Vitest 4.xでは、安定版のBrowser Modeに
toMatchScreenshot()による組み込みビジュアルリグレッション機能が追加され、Vitestユーザーが既存のテストランナーを離れることなく利用できるネイティブな手段が提供されました。
ビジュアルリグレッションテストとは何か?
ビジュアルリグレッションテストは、ウェブページやUIコンポーネントのスクリーンショットを承認済みのベースライン画像と比較することで、意図しないビジュアル変更を検出するテスト手法です。ツールに関わらず、仕組みは同じです。まず正常なレンダリングを一度キャプチャしてベースラインとして保存し、その後の実行ごとに新しいスクリーンショットを撮影してベースラインと差分を比較します。しきい値を超える差分が検出されると、テストが失敗し、人間が承認または却下するために変更がフラグされます。
これはスナップショットテストとは異なります。スナップショットテストはコンポーネントのレンダリングされたマークアップをシリアライズしてテキストの差分を取りますが、ビジュアルリグレッションテストは実際にレンダリングされたピクセルの差分を取ります。マークアップレベルのスナップショットは、純粋にビジュアルな変更——z-indexの変更、カラートークンの入れ替え、フォントのフォールバック——を見逃します。ユーザーが目にするものが変わっていても、シリアライズされたDOMは同一のままだからです。
なぜユニットテストとE2Eテストはビジュアルバグを見逃すのか?
Discover how at OpenReplay.com.
ユニットテストはボタンがonClickハンドラーを発火するかを検証し、E2Eテストはそれをクリックするとフローが完了するかを検証しますが、どちらのテストもボタンの色が間違っていること、12ピクセル右にずれていること、モーダルオーバーレイで隠れていることを検出できません。これがビジュアルリグレッションテストが埋めるギャップです。以下のマトリックスで、その境界を明確に示します。
| シナリオ | ユニット(Jest/Vitest) | E2E(Playwright/Cypress) | ビジュアル |
|---|---|---|---|
| ボタンがDOMにレンダリングされる | ○ | 一部 | ○ |
ボタンがonClickを発火する | ○ | ○ | ✕ |
| クリックでフローが完了する | ✕ | ○ | ✕ |
| ボタンの色が正しい | ✕ | ✕ | ○ |
| ボタンの位置が正しい | ✕ | ✕ | ○ |
| ボタンがオーバーレイで隠れていない | ✕ | ✕ | ○ |
最後の行が本番環境で問題を引き起こすケースです。<CheckoutButton>は正常に動作していたのに、スタッキングコンテキストがそれを覆う<CookieBanner>が追加されたとします。
// CheckoutButton.tsx
export function CheckoutButton({ onCheckout }: { onCheckout: () => void }) {
return (
<button data-testid="checkout" onClick={onCheckout}>
Complete purchase
</button>
);
}
クリックハンドラーの発火を検証するユニットテストはパスします。JestとVitestがデフォルトで使用するDOM実装であるJSDOMは、レイアウトやスタッキングコンテキストを計算しないため、ボタンが覆われていることを認識できないからです。await expect(page.getByTestId('checkout')).toBeVisible()のようなE2Eアサーションもパスします。Playwrightは、要素が空でないバウンディングボックスを持ち、display:noneでない場合に可視と判断するため、より高いz-indexを持つ要素によって上書きされていても、どちらの条件も変わらないからです。後続のlocator.click()はPlaywrightのアクション可能性チェックによって障害を検出できる場合がありますが、可視性アサーション単体では検出できません。スクリーンショットの差分を取れば、バナーがボタンの上に重なっていることが一目でわかります。
ビジュアルリグレッションテストはどのように機能するのか?
ワークフローは5つのステップのループです。ベースラインをキャプチャし、変更後に実行し、新しいスクリーンショットをベースラインと差分比較し、差分をレビューし、変更を承認(ベースラインを更新)するか却下(コードを修正)するかを決定します。Playwrightは--update-snapshotsフローを通じてこれを具体的に実現します。
toHaveScreenshot()を含むテストを初めて実行すると、ベースラインが存在しないため、Playwrightは参照画像を書き込みます。そのベースラインがレビューされてコミットされると、以降の実行ではそれと差分が比較されます。ビジュアル変更が意図的なもの——再設計されたボタン、新しいカラートークン——である場合は、npx playwright test --update-snapshotsを実行し、生成されたHTMLレポートで差分をレビューし、更新された.pngファイルをコミット(バイナリのベースラインにはGit LFSが一般的な手法です)すると、新しいスクリーンショットがベースラインになり、次のCI実行ではそれと比較されます。--update-snapshotsフラグは参照画像をその場で再生成します。
重要な規律は、ベースラインの更新をコードレビューとして扱うことであり、形式的な承認として扱わないことです。差分を確認せずに「テストをパスさせるため」にベースラインを更新することは、実際のリグレッションが新たな承認済みUIになってしまう原因です。
差分技術とテストスコープ
ビジュアルリグレッションツールは4つの差分技術のいずれかを使用し、キャプチャする領域も異なります。差分エンジンの比較は以下の通りです。
| 技術 | 仕組み | トレードオフ |
|---|---|---|
| ピクセル単位 | すべてのピクセルを比較し、差分をフラグする | 精度は高いが、アンチエイリアシングやフォントスムージングによるノイズが多い。しきい値の設定が必要 |
| レイアウト | 要素の位置、サイズ、スペーシングを比較する | 細かいピクセルノイズを無視するが、色やテクスチャの変更を見逃す |
| DOMベース | レンダリングされたピクセルではなく、シリアライズされたマークアップを差分比較する | 構造的な変更を検出するが、レンダリングのみのバグには対応できない |
| AIアシスト | コンピュータービジョンが人間が気づくような変更のみをフラグする | 誤検知を減らす。特定の商用ツールで利用可能 |
キャプチャスコープは別の軸です。コンポーネントレベルのテストは単一のボタン、カード、モーダルを分離します。ノイズが少なく、レビューが速く、デザインシステムやStorybookに最適です。ページレベルのテストは全画面をキャプチャし、実際のユーザージャーニーにおけるレイアウトの問題を検出しますが、動的コンテンツが多くレビューに時間がかかります。実用的なスイートでは両方を使用します。デザインシステムにはコンポーネントレベル、重要なフローにはページレベルを使います。
AIによる差分について特筆すると、この分野はしばしば誤って説明されています。2026年6月時点では、AIベースのビジュアル差分はApplitoolsのコアとなる有料機能です。Percyはピクセル比較の上にAIアシストによるトリアージを追加し、ChromaticはAIではなくピクセルとGitを考慮した差分を使用します。フレームワークネイティブのツール(Playwright、Vitestブラウザモード)は設定可能なしきい値を持つピクセル比較を使用しており、フレーキーネスが上流で制御されていれば、ほとんどのチームにとって十分です。
Playwrightでビジュアルリグレッションテストをセットアップするにはどうすればよいか?
Playwrightはスクリーンショット比較を組み込みアサーションとして提供しているため、@playwright/test以外の追加依存関係は不要です。アサーションはexpect(page).toHaveScreenshot()で、PageAssertions APIリファレンスに記載されています。まず設定から始めます。
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
// ブラウザごとにベースラインを整理し、クロスブラウザのスクリーンショットが衝突しないようにする。
snapshotPathTemplate: '{testDir}/__screenshots__/{projectName}/{arg}{ext}',
expect: {
toHaveScreenshot: {
// サブピクセルレンダリングノイズに対して、絶対ピクセル数のバジェットを許容する。
maxDiffPixels: 100,
// 大きなキャプチャに対して、画像の1%という相対的なバジェットも設定する。
maxDiffPixelRatio: 0.01,
},
},
use: {
// 固定ビューポート: レスポンシブレイアウトの変更でベースラインが無効にならないようにする。
viewport: { width: 1280, height: 720 },
},
// CIで決定論的なベースラインを得るために、ブラウザを1つに固定する。
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});
2つのオプションが重要な役割を担います。maxDiffPixelsとmaxDiffPixelRatioオプションは、テストが失敗するまでに許容される差分の量を設定します。これらが存在するのは、サブピクセルアンチエイリアシングがマシンによって異なるためです。Playwrightのドキュメントには”オペレーティングシステムによって異なるスクリーンショットが生成される場合がある”と明記されています。maxDiffPixelRatio: 0.01のしきい値は、そのノイズを吸収しつつ、実際のリグレッションを隠さない設定です。
理解しておくべきデフォルト値があります。toHaveScreenshot()アサーションでは、animationsのデフォルトが'disabled'になっており、キャプチャ前に有限のCSSアニメーションを終了させ、無限のアニメーションを停止させます。これはpage.screenshot()とは異なり、そちらではanimationsのデフォルトが'allow'です。つまり、アサーションではトランジション途中のフレームはすでに処理されているため、このオプションを追加する必要はなく、デフォルトに依存するだけで十分です。
テストは次のようになります。
// checkout.spec.ts
import { test, expect } from '@playwright/test';
test('checkout button is not obscured', async ({ page }) => {
await page.goto('/checkout');
// Web-firstアサーション: 固定タイムアウトではなく、要素が表示されるまで待機する。
await expect(page.getByTestId('checkout')).toBeVisible();
await expect(page.getByTestId('checkout')).toHaveScreenshot('checkout-button.png');
});
スクリーンショットのスコープをページ全体ではなくロケーターに絞ることで、キャプチャ領域が小さくなり、差分が集中します。これにより、無関係な変更のたびにページ全体を再ベースラインすることなく、<CookieBanner>の重なりを正確に検出できます。
Vitestブラウザモードによるコンポーネントレベルのテスト
スタックがすでにVitestを使用している場合、バージョン4.0でBrowser Modeが安定版に昇格し、toMatchScreenshot()による組み込みビジュアルリグレッション機能が追加されました。これは4.xの機能であり、2.x系にはこのようなアサーションはありません。そのため、^4.0以上を対象にしてください。セットアップにはPlaywrightプロバイダーを使用します。
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
export default defineConfig({
test: {
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium' }],
},
},
});
// Button.visual.test.ts
import { expect, test } from 'vitest';
import { page } from 'vitest/browser';
import { render } from 'vitest-browser-react';
import { CheckoutButton } from './CheckoutButton';
test('checkout button matches baseline', async () => {
render(<CheckoutButton onCheckout={() => {}} />);
// 特定の要素ロケーターをキャプチャする。Vitestのドキュメントでは、
// ページ全体のキャプチャはアンチパターンとして指摘されている。
await expect(page.getByTestId('checkout')).toMatchScreenshot();
});
プロバイダーは@vitest/browser-playwrightのplaywright()関数をオブジェクトとして渡します。以前のバージョンで使用されていた文字列'playwright'ではありません。コンテキストのインポートはvitest/browserです。既存のブラウザモードのセットアップをアップグレードする前に、Vitestのマイグレーションガイドを確認してください。v4ではこれらのインポートパスが変更されています。
Storybookストーリーによるコンポーネントレベルのテスト
コンポーネントがStorybook(現在v10.4)にある場合、ビジュアルカバレッジを追加するために何も書き直す必要はありません。すでにメンテナンスしているストーリーを再利用できます。jest-image-snapshotとtest-runnerのpostVisitパターンを組み合わせた古い手法は、現在はレガシーなNodeパスです。jest-image-snapshotはNode.jsに依存しているため、StorybookのVitestベースのブラウザモードでは動作しません。
現在の無料かつローカルで使える代替手段は、Storybookのポータブルストーリー API(composeStories)を使ってストーリーをVitestブラウザモードのテストに取り込み、上記のVitestセットアップと同じtoMatchScreenshot()でアサーションすることです。
// CheckoutButton.visual.test.tsx
import { expect, test } from 'vitest';
import { page } from 'vitest/browser';
import { render } from 'vitest-browser-react';
import { composeStories } from '@storybook/react-vite';
import * as stories from './CheckoutButton.stories';
// ストーリーをそのargs、デコレーター、プロジェクトアノテーションとともに合成し、
// テストがストーリーで定義されたのと全く同じコンポーネントの状態をレンダリングするようにする。
const { Primary } = composeStories(stories);
test('CheckoutButton matches its story baseline', async () => {
render(<Primary />);
// ページ全体ではなく要素にスコープを絞る。
await expect(page.getByTestId('checkout')).toMatchScreenshot();
});
これにより、コンポーネントのレンダリング状態とビジュアルベースラインの両方に対して、ストーリーという単一の情報源が維持されます。知っておくべき注意点が2つあります。このアサーションはStorybook Vitestアドオンを通じてではなく、スタンドアロンのVitestブラウザモードテストとして実行されます。アドオンはtoMatchScreenshotをサポートしておらず(Invalid Chai property: toMatchScreenshotというエラーが発生します)、インタラクションテストとアクセシビリティテストを担当しており、ピクセル差分には対応していません。また、Storybookのファーストパーティのビジュアルテスト製品はChromaticという有料のクラウドサービスです。リポジトリにベースラインを保存する代わりに、ホスト型のクロスブラウザベースラインと管理されたレビューワークフローが必要な場合は、Chromaticを選択することになります。
ビジュアルテストが頻繁に失敗し続けるのはなぜか?
ビジュアルテストのフレーキーネスのほとんどは4つの原因に起因します。トランジション途中のフレームを生むCSSアニメーション、スクリーンショット撮影後に読み込まれるウェブフォント、実行間で正当に異なる動的コンテンツ(タイムスタンプ、アバター、カウンター)、そしてGPUやOSによって異なるサブピクセルアンチエイリアシングです。それぞれに具体的な対処法があり、しきい値を緩めることは解決策ではありません。ノイズを抑えるためにmaxDiffPixelsを高く設定すると、実際のリグレッションも見逃すようになり、本末転倒です。
スクリーンショット撮影前に制御可能な原因に対処するための安定化ヘルパーを以下に示します。
// prepare-page.ts
import { Page } from '@playwright/test';
export async function preparePageForScreenshot(page: Page) {
// 1. アニメーションとトランジションの持続時間をゼロに強制する。
// toHaveScreenshotはすでにそれらを無効化しているが、page.screenshot()や
// 途中の可視状態にも対応するための二重の安全策として。
await page.addStyleTag({
content: `*, *::before, *::after {
animation-duration: 0s !important;
transition-duration: 0s !important;
}`,
});
// 2. ウェブフォントを待機する。document.fonts.readyはフォントの読み込みが
// 完了したときに解決され、テキストをずらすフォールバックフォントのリフローを防ぐ。
await page.waitForFunction(() => document.fonts.ready.then(() => true));
// 3. すべての画像の読み込み完了を待機する。
await page.waitForFunction(() =>
Array.from(document.images).every((img) => img.complete)
);
// 4. あちこちでモックするのではなく、動的コンテンツを無効化する。
await page.locator('[data-dynamic]').evaluateAll((els) =>
els.forEach((el) => ((el as HTMLElement).style.visibility = 'hidden'))
);
}
ステップ2はdocument.fonts.readyを利用しています。これはドキュメントのフォント読み込みとレイアウト操作が完了したときに解決されるPromiseを返します。「フォントが描画された」ことを示す正確なシグナルであり、固定のスリープよりはるかに信頼性が高いです。
このヘルパーが意図的に行わないことに注目してください。page.waitForTimeout()は一切呼び出していません。Playwrightのベストプラクティスガイドでは、固定タイムアウトを推奨していません(“本番環境でタイムアウトを待機しないこと”)。明らかな代替手段であるwaitForLoadState('networkidle')も同様に推奨されていません。Playwrightのドキュメントでは”準備完了の判断にはウェブアサーションを使用すること”と述べられています。正しいアプローチはWeb-firstアサーションと明示的な要素待機であり、toHaveScreenshot()はすでに2回連続してスクリーンショットが一致するまで自動リトライするため、残存するタイミングノイズのほとんどを自力で吸収します。
表示させたままにしたいが差分比較では無視したい動的な領域については、Playwrightのmaskオプションがロケーターの配列を受け取り、差分比較前にそれぞれをソリッドボックスで塗りつぶします。
await expect(page).toHaveScreenshot('dashboard.png', {
mask: [page.getByTestId('last-login'), page.locator('img[data-avatar]')],
});
これはCSSの可視性ハックよりもクリーンな方法です。マスクされた領域はレイアウト上のスペースを占有し続けるため、周囲のレイアウトは実際のレンダリング通りに差分比較されます。
誤検知なしにCIでビジュアルテストを実行するにはどうすればよいか?
フォントレンダリングはUbuntu、macOS、Alpine Linuxで、同じブラウザバージョンでも異なります。そのため、開発者のMacBookでキャプチャしたベースラインは、GitHub ActionsのUbunnerランナーで撮影したスクリーンショットと比較すると誤検知を生じます。この変数を排除するために、CIを特定の公式mcr.microsoft.com/playwright:v1.61.0-noble Dockerイメージに固定してください。これらのイメージでは:latestのようなローリングタグは公開されなくなったため、固定バージョンタグの使用は任意ではなく必須です。
これに続く実践的なルールは、CIが使用するのと同じコンテナ内でベースラインを生成することです。異なるOSでローカルにキャプチャしたベースラインは、CI固有の差分の最も一般的な原因です。CIで(または固定コンテナをローカルで使用して)参照を生成し、それをコミットしてください。
ベースライン自体については、Git LFSが一般的な手法です。PNGファイルはバイナリであり、時間の経過とともにリポジトリを肥大化させます。.gitattributesのエントリでGit LFSにルーティングします。
tests/**/__screenshots__/** filter=lfs diff=lfs merge=lfs -text
ベースラインの差分はコードと同様にプルリクエストでレビューしてください。CIでビジュアルテストが失敗した場合は、PlaywrightのHTMLレポートをビルドアーティファクトとしてアップロードし、レビュアーがサイドバイサイドの差分を確認できるようにします。承認された変更は、それを引き起こしたコードと同じPRにおける意図的なベースラインコミットです。何が変わったかを隠す別の「テストを修正する」コミットは避けてください。
何をテストし、何をテストしないべきか?
ビジュアル変更がコストを伴うコンポーネントとフロー——壊れたチェックアウトボタン、崩れたナビゲーション、40ページで使用されるデザインシステムコンポーネント——をテストし、コンテンツが実行ごとに正当に変わる高度に動的なダッシュボードやサードパーティウィジェットはスキップしてください。ビジュアルテストのコストはすべての差分に対するレビュー時間です。リグレッションが高コストでレンダリングが安定している箇所に集中させてください。
テストに適した候補: デザインシステムコンポーネント、重要なコンバージョンフロー(認証、チェックアウト)、ナビゲーション、エラー状態と空の状態、レスポンシブブレークポイント。テストに不向きな候補: リアルタイムダッシュボード、ユーザー生成コンテンツ、広告スロット、管理外のサードパーティ埋め込み——これらは絶え間ない差分を生み、レビュアーが確認せずに承認するよう訓練してしまいます。これは最悪の結果です。
ビジュアルテストの限界: 本番環境
ビジュアルリグレッションテストはCIの制御された環境でベースラインに対してUIを検証しますが、本番環境は実際のデバイス、制御されていないビューポート、読み込みに失敗する可能性のあるフォント、ページをリフローするサードパーティスクリプト上でレンダリングされます。セッションリプレイは記録されたセッションを再構築して再生するため、テストスイートをすり抜けたレイアウトの崩れが、影響を受けたセッションの記録として表面化します。
テストマトリックスはビューポートとブラウザを1つに固定していますが、ウェブフォントがタイムアウトしてすべてのラベルが長くなるロケールを持つ1366×768のノートパソコンを使用しているユーザーは、ベースラインが記述したことのないレイアウトに遭遇します。ビジュアルテストはPRの境界での予防策であり、セッションリプレイは本番環境の境界での検出手段です。両者は異なる障害モードをカバーしており、補完的な関係にあります。
ビジュアルリグレッションテストツールはどのように選べばよいか?
ビジュアルリグレッションツールは3つのカテゴリに分類されます。フレームワークネイティブランナー、クラウドサービス、セルフホスト型ツールです。選択はチームのニーズに基づくものであり、機能リストではありません。フレームワークネイティブランナー——PlaywrightとVitestブラウザモード——は無料で既存のCIで動作し、ベースラインをリポジトリに保存します。トレードオフは、ベースラインとクロス環境の一貫性を自分で管理する必要があることです。PercyやChromaticのようなクラウドサービスはベースラインのストレージとレビューワークフローを提供し、無料ティアも用意されています(Percyは月5,000スクリーンショットを含み、Chromaticの有料プランは月$179から)が、外部依存が生じます。BackstopJSのようなセルフホスト型オプションは、より多くの設定オーバーヘッドを伴いながらすべてを社内に保持します。すでにPlaywrightまたはVitestをCIで実行しているチームにとっては、ネイティブなパスは追加コストがなく、フレーキーネスが上流で制御されていれば十分です。
フレームワークネイティブなアプローチは、新たなベンダーも継続的なコストも不要で、機能テストが構造的に見逃すバグのクラスを解消します。まず最も重要なコンポーネント——チェックアウトボタン、プライマリナビゲーション——にtoHaveScreenshot()アサーションを1つ追加し、CIが使用するのと同じコンテナ内でベースラインを生成し、最初の失敗した差分をテストを黙らせるためのものではなくコードレビューとして扱うことから始めてください。
よくある質問
ビジュアルリグレッションテストとスナップショットテストの違いは何ですか?
スナップショットテストはコンポーネントのレンダリングされたマークアップをテキストにシリアライズしてその文字列の差分を取りますが、ビジュアルリグレッションテストはスクリーンショットの実際のレンダリングされたピクセルの差分を取ります。マークアップレベルのスナップショットは、z-indexの変更、カラートークンの入れ替え、フォントのフォールバックなど、純粋にビジュアルなものを見逃します。ユーザーが目にするものが変わっていても、シリアライズされたDOMは同一のままだからです。ビジュアルリグレッションは、スナップショット差分が構造的に検出できないレンダリングのみのバグを捕捉します。
ビジュアルテストがローカルではパスするのにCIで失敗するのはなぜですか?
フォントレンダリングとサブピクセルアンチエイリアシングはオペレーティングシステムによって異なるため、macOSのノートパソコンでキャプチャしたベースラインは、GitHub ActionsのUbuntuランナーで撮影したスクリーンショットと比較すると誤検知を生じます。解決策は、異なるOSでキャプチャした参照をコミットするのではなく、CIが使用するのと同じ固定コンテナ(公式のmcr.microsoft.com/playwright:v1.61.0-noble Dockerイメージなど)内でベースラインを生成することです。クロス環境のベースラインはCI固有の差分の最も一般的な原因です。
ビジュアルリグレッションテストにPercyやApplitoolsのような有料サービスは必要ですか?
必要ありません。Playwrightはexpect(page).toHaveScreenshot()を通じてスクリーンショット比較を組み込みアサーションとして提供しており、Vitest 4.xは安定版のBrowser ModeにtoMatchScreenshot()による組み込みビジュアルリグレッション機能を追加しました。どちらも無料で既存のCIで動作し、ベースラインはリポジトリに保存されます。PercyやChromaticのような有料サービスはベースラインのストレージとホスト型レビューワークフローを追加し、ApplitoolsはAIベースの差分を追加しますが、フレーキーネスが上流で制御されていれば、設定可能なしきい値を持つフレームワークネイティブのピクセル比較でほとんどのチームには十分です。
ビジュアルテストではページ全体をキャプチャすべきですか、それとも単一のコンポーネントをキャプチャすべきですか?
可能な限り、ページ全体ではなく特定の要素ロケーターにスクリーンショットのスコープを絞ってください。コンポーネントレベルのキャプチャは差分を集中させ、ノイズを減らし、レビューを速め、無関係な変更のたびにページ全体を再ベースラインすることを避けられます。Vitestのドキュメント自体もページ全体のキャプチャをアンチパターンとして指摘しています。要素間のレイアウトの相互作用が重要なチェックアウトのような重要なエンドツーエンドフローにはページレベルのキャプチャを使用し、デザインシステムのパーツや独立したUIにはコンポーネントレベルのテストを使用してください。