zudo-test-wisdom
GitHub リポジトリ

検索したい単語を入力

いつでも検索バーを開ける

コミット済みベースラインによる決定的ビジュアルリグレッション

Playwright の toHaveScreenshot() とコミット済みベースライン PNG -- Level 5 のスキルセットに欠けている決定的なピクセル差分ゲート、そしてそれを健全に保つ決定性と許容値の規律。

Level 5 は、決定的な算出スタイルアサーションと 非公式な スクリーンショットレビューを組み合わせる -- ただしこれは明示的に「再現可能なリグレッションゲートではない」。なぜなら現在のガイドのスキルセットにはピクセル差分ツールが存在しないからだ。ここには本当のギャップが残る。純粋に 視覚的な リグレッション -- ずれたグラデーション、色が変わった SVG、1 ピクセルずれて描画された canvas フレーム -- を決定的な合否シグナルとして捉えるものが、この分類体系には何もない。このページがそれを埋める。Playwright の toHaveScreenshot() は、新たにレンダリングしたスクリーンショットを コミット済みベースライン PNG と比較し、設定した許容値を超えて乖離したらランを失敗させる。これは Level 5 ファミリーの決定的な スクリーンショット側の半分 だ。非公式な確認と「テストが何を見られるか」は同じでありながら、健全性チェックの一瞥ではなくゲートとして配線されている。

問題は、ピクセルゲートはその決定性の分しか信頼できない、という点だ。作り込まれていないスクリーンショットテストは、ブラウザスイートで偽陽性の赤フレークを生む最も一般的な原因の一つであり、その反射的な対処 -- 通るまで許容値を緩める -- はまさに このガイドが禁じているゲート改ざん にほかならない。このページの大半は、ゲートを厳格に保てるだけの決定性を勝ち取ることについてだ。

toHaveScreenshot() の仕組み

その仕組みはコミット済みの参照画像だ。スペックはロケータをレンダリングし、テストの隣に保存された PNG と比較する。

// e2e/visual/hero.spec.ts
import { test, expect } from "@playwright/test";

test("hero section matches its baseline", async ({ page }) => {
  await page.goto("/");
  await expect(page.locator(".hero")).toHaveScreenshot("hero.png", {
    maxDiffPixels: 100,
  });
});

ベースラインが無い状態での初回ランは、スナップショットディレクトリに hero.png を書き出し、失敗する -- Playwright は「比較対象の参照が無い」ことを、静かな成功ではなく失敗として扱う。(これは remark/rehype のゴールデンフィクスチャコーパス が HTML に対して適用しているのと同じ原則だ。コミット済みベースラインが見つからないランは、たった今生成したものを採用するのではなく、はっきり失敗する。)以降のランは毎回レンダリングし直し、コミット済み PNG と差分を取り、差が許容値未満のときだけ合格する。

許容値のつまみは 2 つ、加えてピクセル単位の感度が 1 つあり、その違いは重要だ。

オプション意味使いどころ
maxDiffPixels許容する差分ピクセルの絶対数固定された小さな領域で、正確なピクセル予算を見積もれる場合
maxDiffPixelRatio許容する差分ピクセルの割合(0〜1)絶対数がビューポートに比例して増えるフルページ・レスポンシブなショット
thresholdピクセル単位の色距離感度(0〜1、既定 0.2)そもそも 1 ピクセルがどれだけ違えば「差分」と数えるかの調整

規律をスペックごとに継承させるため、既定値は設定ファイルで一度だけ定める。

// playwright.config.ts
import { defineConfig } from "@playwright/test";

export default defineConfig({
  expect: {
    toHaveScreenshot: {
      maxDiffPixelRatio: 0.01, // ≤1% of pixels may differ
      threshold: 0.2, // per-pixel color-distance sensitivity (0–1)
      animations: "disabled", // freeze CSS animations/transitions for the shot
    },
  },
  snapshotPathTemplate:
    "e2e/visual/__screenshots__/{testFilePath}/{arg}-{projectName}-{platform}{ext}",
});

{projectName}{platform} のトークンが要となる。Playwright は既定でベースラインをプロジェクトと OS でキー付けする。同じページでも chromium-linuxwebkit-darwin では描画結果が異なるからだ。ベースラインは 同じ プロジェクト・プラットフォームのスクリーンショットとしか比較されない -- これは、CI がベースラインを撮影したのと同じ OS でレンダリングしなければならない理由でもある。

ベースラインはコードと同じくレビューされる

コミット済みの PNG は、使い捨てのキャッシュではなく記録としての成果物だ。リポジトリに存在し、PR の差分に現れ、レビュアーはコード変更を読むのとまったく同じように、レンダリング前後を見比べる。この一つの判断 -- ベースラインをバージョン管理に置くこと -- こそが、スクリーンショット比較を、自らを静かに書き換えるスナップショットではなく、リグレッション ゲート に変える。

したがって再生成は、明示的でレビュー可能な行為でなければならず、決して自動であってはならない。それを担保する規律が 2 つある。

  • オンデマンドで再生成し、通常のランでは決して再生成しない。 npx playwright test --update-snapshots はローカルでベースラインを書き換える。素のランは常に比較するだけだ。不一致のたびに自らのベースラインを静かに書き換えるテストはゲートではない -- 失敗しようがない。

  • 描画に敏感な面では、再生成を明示的なスイッチの背後に置く。 pgen -- ドライバとアンチエイリアスによって PNG 出力が微妙に変わる GPU パターンジェネレータ -- では、ベースライン撮影は明示的な PGEN_REGEN_BASELINES モードでのみ実行される。まさに、描画がゲートに使えるほど安定している一方で、気軽に撮り直せるほどには安定していなかったために採り入れられた。zzmod も同じ理由で専用のビジュアルベースライン撮影スクリプトを備える。撮影はスイート実行とは区別された、意図的なコマンドなのだ。

PR に現れる意外な差分は、意図しないリグレッションを意味する。予期した差分は それ自体が 新しいベースラインであり、それをコミットすることは、レビュアーが新しい視覚的な正解を受け入れることだ。これはゴールデンフィクスチャコーパスが HTML に対して用いるのと同じレビューループだ -- 参照が変わることはレビュー対象のイベントであって、テストを走らせた副作用として静かに起こることではない。

Warning

「とりあえず --update-snapshots で撮り直す」は、失敗しているアサーションを削除するのとビジュアルリグレッションにおいて等価だ。それが正しいのは、すでに 差分を見て新しい描画が正しいと判断したとき だけ だ。何が変わったかを確認せずに、赤を緑に変えるために再生成することは、リグレッションをベースラインへとロンダリングする -- そしてそれ以降のランはすべて、汚染された参照に対して合格してしまう。

決定性を作り込む

ピクセルゲートは、同じ入力が 2 つの異なる画像をレンダリングした瞬間に失敗する。だから描画における非決定的な要因はすべて、最初のベースラインを撮影する前に固定しておかねばならない。よくある容疑者は次のとおり。

  • アニメーションとトランジション。 トランジションの途中で撮ったショットはコイントスだ。animations: "disabled" を設定する(Playwright は有限の CSS アニメーションとトランジションを終了状態へ早送りし、無限のものは初期状態へキャンセルする)か、prefers-reduced-motion を強制する -- これは デフレークレシピ がアニメーション関連のフレークに用いるのと同じ reduced-motion の手法だ。

  • 点滅するキャレットとフォーカスリング。 テキストキャレットは点滅するが、toHaveScreenshot は既定でそれを隠す(caret: "hide")。ただし自前で描くカスタムカーソルやフォーカスリングは対象外なので、注入 CSS(caret-color: transparent)で隠すか、要素をマスクする。

  • ウェブフォントの読み込み。 ウェブフォントが読み込まれる前に撮ったショットは、フォールバックフォントのメトリクスを捉える -- グリフ幅が違い、行の折り返しが違う。アサートの前に document.fonts.ready を待つ。

  • 動的コンテンツ。 タイムスタンプ、ランダム ID、ライブデータ -- toHaveScreenshotmask オプションでマスクするかスタブ化し、比較対象を、本当にゲートしたいレイアウトだけにする。

  • ビューポートとデバイスの固定。 スクリーンショットのサイズはビューポートの関数だ。viewport(および deviceScaleFactor)をプロジェクトごとに固定し、ダーク・ライトのスキームは colorScheme で別々のベースラインとして撮影する。

  • GPU とアンチエイリアスのばらつき。 ここが難所だ。サブピクセルアンチエイリアスと GPU 描画はドライバやランナーによって異なる -- pgen が再生成モードと同一ハードウェアでの撮影方針をそもそも必要とした理由だ。ばらつきを排除できない場合は、その分 だけ を許容値で吸収し(次節)、描画を一貫したハードウェアに固定する。

// A determinism-hardened visual spec
test("card renders identically", async ({ page }) => {
  await page.goto("/cards/42");
  await page.evaluate(() => document.fonts.ready); // fonts settled
  await page.emulateMedia({ reducedMotion: "reduce" }); // no animation mid-frame
  await expect(page.locator(".card")).toHaveScreenshot("card.png", {
    mask: [page.locator(".timestamp")], // exclude volatile content
    maxDiffPixelRatio: 0.005,
  });
});

Tip

ベースラインは ゲートを実行するのと同じ環境で 撮影すること。開発者の macOS マシンで撮ったベースラインを Linux の CI 描画と比較すれば、フォントヒンティングとアンチエイリアスだけで差分が出る -- 製品とは何の関係もない、確実な偽陽性の赤だ。これがベースラインをプラットフォームでキー付けする理由であり、CI が自身のランナーイメージ上で再生成する理由だ。

許容値はノイズを吸収するためのもので、リグレッションのためではない

許容値はただ一つの理由のために存在する。排除できないと証明したレンダリングノイズを吸収するためだ -- GPU が描く曲線沿いの数ピクセルのアンチエイリアス、サポートせざるを得ないプラットフォームでのサブピクセルのフォントヒンティング。それはノイズフロアであって、リグレッションの許容枠ではない。判定は方向で見る。

  • 正当: 物理的なノイズ源を名指しでき、許容値をそのすぐ上に設定し、それでも本物の視覚的変化はそれを超えて失敗する。

  • 不当: 本物の差分が失敗しているのに、通るまで許容値を上げる。それが ルール 8 -- ゲートを決して改ざんしない だ。ガイドは「緩めたスクリーンショット許容値(例: 引き上げたピクセルしきい値)」を、アサーションの削除やテストのスキップと同じリストに並ぶ、ゲート改ざんの具体例として名指ししている。

Danger

maxDiffPixels / maxDiffPixelRatio / threshold を、赤いランを緑に変えるために緩めることは、既定で疑わしいものとして扱わねばならない。required-behavior のルールに従えば、そうした編集にはリンクされた issue と、差分の フレッシュコンテキストレビュー が必要だ -- 不一致を引き起こした変更を書いたのと同じセッションでは適用してはならない。広げられた許容値は、設定ファイルのレベルでは、本物のリグレッションが見逃されて素通りしたのと区別がつかない。両者を区別できるようにするのはレビューだけだ。

健全な進む方向は 締める ことだ。決定性を作り込むほど(フォント、アニメーション、ハードウェアの固定)、ノイズフロアは下がり、許容値もそれとともに下げるべきだ。増える一方の許容値は、少しずつ盲目になっていくスイートだ。

この層の位置づけ

これは 決定的な Level 5 ファミリーのゲート だ -- 算出スタイルアサーションと同じ、再現可能で LLM を介さない合否の性質を持ちながら、特定のプロパティ値ではなく視覚的な出力に狙いを定めている。算出スタイルチェックを置き換えるものではなく、両者は異なる失敗の形をカバーする。

状況選ぶもの
あるプロパティが正確な値と一致しなければならない(font-size: 48pxheight0px でない)算出スタイルアサーション(Level 5
グラデーション、シャドウ、SVG の色変え、canvas/GPU 出力 -- 名指しできる単一のプロパティが無い視覚的な正解コミット済みベースラインのピクセル差分(このページ)
プロパティとして列挙するには広すぎる、豊かなレイアウトの相互作用コミット済みベースラインのピクセル差分
移り変わるコンテンツ、クロスプラットフォームのフォントばらつきが大きい、テキスト主体のページ算出スタイルアサーション -- ピクセル差分はフレークする
安定した DOM 矩形が無い、算出スタイルが適用されない、ピクセル差分がノイズ過多で決定的になれないLevel 6 の AI 判定 -- 最終手段

算出スタイルは「この一つの値は正しいか」に答える。ベースラインのピクセル差分は「レンダリングされた領域全体が、いまも参照のように見えるか」に答える -- グラデーションや canvas については、toHaveCSS アサーションを有限に並べたどんなリストが表現できるより厳密に多くを、そして毎回コンテンツが正当に変わるページについては厳密に劣る形で。レンダリングされた画像そのものが 製品 である面 -- SVG の色マッピングパネル(zpanels)、レイアウトが製品であるダッシュボード(zudome)-- では、「正しい」を代表する単一のプロパティが存在せず、コミット済みベースラインの差分だけが、ユーザーが見るものを見る唯一のリグレッションネットだ。

Level 6 との境界は決定性だ。コミット済みベースラインの差分は再現可能で CI に属する。L6 の AI 判定は非決定的でコストを伴い、明示的に CI ゲートではない。面が安定した矩形を持たない <canvas>かつ ピクセル差分が信頼するにはノイズ過多なとき -- ガイドが同時に要求する 2 つの条件 -- が、この層を越えて L6 へエスカレートする狭いケースであって、ここで許容値を緩める理由ではない。

CI の現実

  • 保管: リポジトリ内 vs アーティファクト。 コミット済みベースラインはレビュー可能な既定だ -- 差分 レビューだ。その代償はリポジトリの重さ。PNG はバイナリで、パックを膨らませ、デバイス・スキーム・OS の広いマトリクスは一気に倍増する。ベースラインは、実際にピクセルゲートに値する面だけにスコープを絞り、あらゆるページをベースライン化しないこと。ベースラインを CI アーティファクトや外部ストレージに保管するチームもある -- リポジトリの重さは減らせるが、ゲートを健全にする PR 差分レビューを失う。だからサイズがトレードオフを強いるまでは、リポジトリ内を選ぶ。

  • OS ごとのベースライン。 描画はプラットフォームで異なるため、ゲートする OS ごとに、その OS 上で撮影した独自のベースラインセットが要る。開発者のラップトップではなく CI のランナーイメージ上で生成し、参照がゲートの環境と一致するようにする。

  • 更新フロー。 ローカルで npx playwright test --update-snapshots を実行し、コミット前に 結果の PNG 差分を PR でレビューする。レビュー対象のイベントは変更されたベースラインだ。視覚的な説明なしにベースラインを更新する PR は、まさに 許容値のルール が捉えようとしている臭いだ。

  • GPU に依存するベースラインはスケジュールに属する。 描画が PR ランナーに無い実 GPU を必要とするとき、ピクセルゲートは毎 PR で判定できない -- これは典型的な ヘビーテストのケース B だ。より低いレベルへ降格させるのではなく、スケジュール再試験 の中で能力あるハードウェア上で実行する。レベルは正しく、動くのはティアだけだ。

次に読むもの

Revision History

作成更新