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-linux と webkit-darwin では描画結果が異なるからだ。ベースラインは 同じ プロジェクト・プラットフォームのスクリーンショットとしか比較されない -- これは、CI がベースラインを撮影したのと同じ OS でレンダリングしなければならない理由でもある。

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

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

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

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

  • 描画に敏感な面では、再生成を明示的なスイッチの背後に置く。 pgen -- ドライバとアンチエイリアスによって PNG 出力が微妙に変わる GPU パターンジェネレータ -- では、ベースライン撮影は明示的な PGEN_REGEN_BASELINES モードでのみ実行される。まさに、描画がゲートに使えるほど安定している一方で、気軽に撮り直せるほどには安定していなかったために採り入れられた。Takazudo Modular(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、ライブデータ -- toHaveScreenshot の mask オプションでマスクするかスタブ化し、比較対象を、本当にゲートしたいレイアウトだけにする。

  • ビューポートとデバイスの固定。 スクリーンショットのサイズはビューポートの関数だ。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 と、差分の フレッシュコンテキストレビュー が必要だ -- 不一致を引き起こした変更を書いたのと同じセッションでは適用してはならない。広げられた許容値は、設定ファイルのレベルでは、本物のリグレッションが見逃されて素通りしたのと区別がつかない。両者を区別できるようにするのはレビューだけだ。

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

ベースラインをオラクルとして受け入れる

ベースラインを撮影することと、それをオラクルとして 受け入れる ことは別の行為だ。撮影されたものは、あるコマンドが生成したファイルにすぎない。オラクルとは、このファイルこそが正しいビルドの姿だ、という主張のことだ。ここまでの内容は、その主張を可能にする決定性を勝ち取るためのものであって、これからコミットしようとしている具体的なバイト列についてその主張が実際に成り立つことを検証するものではない。汚れたツリー、途中までのビルド、古いままの成果物ディレクトリから撮られたキャプチャは、PR の差分では良いものとまったく同じに見え、誰も再現を試みない限り、その偽りの地位を保ち続ける。

撮影とオラクルの間の隙間を埋めるルールが 5 つある。

  1. クリーンで固定された親でキャプチャする。 ベースラインは 親 コミットの出力を記述するものだから、親のクリーンなチェックアウトから、ゲートが固定するのとまったく同じように環境を固定して撮影する。トピック自身の変更の上で撮ったキャプチャは、その変更を検出できない。

  2. ベースラインを受け入れる前に、変更していない親を独立に再実行する。 親をクリーンな状態からもう一度ビルドし、その出力を保存済みのキャプチャと比較する。自らが記述していると主張するまさにそのコミットで再現できないキャプチャはオラクルではない -- その日たまたま作業ツリーにあったものの記録にすぎない。

  3. 説明のつかない不一致は、ベースラインの受け入れ時点で失敗させる -- 発見を最終検証まで先送りしない。 再実行は受け入れ時点で走る、それ自体が一つのゲートだ。先送りすれば、トピックブランチ全体が、誰も検証していない参照に対してビルドされレビューされることになり、不一致は最悪のタイミングで表面化する。変更に紐づいた形、つまりリグレッションに見える形で。

  4. 不良キャプチャの発覚が遅れたときは、修復の前に三角測量する。 失敗した差分だけから契約を修復してはならない -- 後述の 3 点比較を実行し、不一致の証拠を保全する。それらの差分は、その後オラクルに加える変更を正当化する記録であり、バージョン付きの証拠契約 と同じ精神に立つ。

  5. フレッシュコンテキストレビューが食い違いを分類するまで、オラクルを書き換えたり緩めたりしない。 キャプチャを再生成することも、受け入れる範囲を広げることも、ピクセルしきい値を引き上げること と同種の行為であり、同じ要件を伴う。変更を書いた本人ではない誰かが、まず食い違いを分類する -- 古いキャプチャなのか、間違った契約なのか、本物のリグレッションなのか。

Note

このプロトコルはスクリーンショットに固有のものではない。キャプチャされたビルド出力のオラクル はどれも同じ手順で受け入れられる。アセットマニフェスト、生成コードのハッシュ、ビルド済みサイトの整合性スナップショット、バンドルサイズの記録。いずれの場合も、キャプチャはコマンドが生成したファイルであり、オラクルはそのファイルが正しいビルドを記述しているという主張であり、両者の距離を埋めるのは再実行だけだ。ファイルが存在することは何も証明しない。

C/P/T の三角測量

キャプチャされたオラクルとトピックブランチが食い違うとき、失敗した差分だけでは どちら側が 間違っているかを言えない。三角測量は、同じ固定環境で同じコマンドによって 生成された 3 つの成果物でそれに答える。

成果物何であるか
C保存済みのキャプチャ -- 疑いの対象となっているオラクル
P変更していない親 をクリーンに再実行した新鮮な出力
Tトピック をクリーンにビルドした新鮮な出力

帰属のルールは、この 3 つからそのまま導かれる。

C != P                           -> the capture does not reproduce; C is stale
C -> T shows that same mismatch  -> C is not admissible evidence against the topic
P -> T                           -> the only comparison attributable to the change

C != P かつ C -> T の比較が同じ不一致を示すなら、その不一致はトピック以前から存在している。コードの変更に帰属できるのは P -> T だけだ。 契約に手を触れる前に、3 つの差分をすべて保全すること。それらが「オラクルが間違っていた」と「変更が間違っていた」を分けるものであり、証拠を残さずに修復された契約は、こっそり再生成された契約と見分けがつかない。

実例。 親 SHA で撮影された一時的な生成アセットのハッシュオラクルが、その同じ SHA で再現できなかった -- キャプチャとクリーンな親を比べると、14 個のアセット名が削除され、14 個が追加 されていた。続いてトピックも、キャプチャに対してまったく同じ不一致を示した。これは壊れたトピックではなく、古い C の兆候だ。クリーンな親とクリーンなトピックの直接比較(P -> T)で違っていたのは、5 つの新しい changelog ページによって生じた、意図された island のハッシュがちょうど 1 つだけ。CSS とすべてのテーマのハッシュは変わっていなかった。

親の再実行が無ければ、その古いキャプチャは実装を誤って責めていただろう -- あるいはもっと悪く、説明のつかない 14/14 の差分を新しい参照へとロンダリングする盲目的な再生成を招いていただろう。3 点の証拠は、原因がトピックではなく オラクルの契約 にあることを特定し、その契約を、正直には維持できない厳密な名前一致から、新規の失敗が無いことのチェックへと狭める判断を支えた。

ツールチェーンの同一性はベースラインの一部

厳密なコンパイル済みバイト を記録するベースライン -- wasm ペイロードのサイズ、gzip 済みバンドルのバイト数、生成物のハッシュ -- では、生成した側のコンパイラと最適化器のバージョンは、スクリーンショットにとっての OS と同じくらい確実に、そのベースラインの同一性に属する。Playwright はスナップショットを {platform} でキー付けすることでそれを明示している。バイト厳密なサイズマニフェストには通常そうしたキーが無く、ツールチェーンのバージョンは記録から静かに抜け落ち、その数値は移植可能でないのに移植可能に見える。

実例。 ローカルの監査は、1.96 に解決された浮動の stable Rust チャンネル上で走り、gzip-9 の出力としてルートバンドル 1,451,831 B、パーサ 259,809 B を記録した。のちに PR のゲートは 同じ浮動チャンネル を Rust 1.98 に解決し、決定的に 1,442,270 B と 260,760 B を生成した(最終 wasm はそれぞれ 3,293,554 B と 645,272 B)。片方は減り、もう片方は増え、その間にソースの変更は無い。コミット済みの厳密なマニフェストは移行前の成果物を記述したままだったので、すべての gzip ペイロードが独立した上限を余裕をもって下回っていたにもかかわらず、CI は 正しく失敗した。

コードは何も間違っていなかったし、ゲートも何も間違っていなかった。間違っていたのは、生成者を同一性から欠落させたベースラインのほうだ。ルールはこうなる。

ツールチェーンの同一性を固定するか、さもなくば、浮動チャンネルが再測定をレビュー付き・バージョン付きのイベントにすることを受け入れるか。

浮動チャンネルは正当な選択だ。ただコストの置き場所が変わるだけだ。固定すれば、マニフェストは意図的にピンを上げるまで有効であり続ける。浮動させるなら、ツールチェーンが動くたびに、レビュアーが新しい数値を見るベースライン更新イベントが発生する -- スクリーンショットの再生成が通るのとまったく同じレビューループだ。

上限と厳密バイトの記録は別の契約

この事例は、独立して述べるに値する区別の上に立っている。2 つのアサーションは受け入れのルールが異なり、両者を混同すると、脆いゲートか盲目なゲートのどちらかが出来上がる。

契約主張することツールチェーンが動いたとき不一致のとき
サイズ上限出力が自分で決めた予算を超えないこと有効なまま -- 数キロバイトの増減は予算を無効にしない本物のシグナル。何かが実質的に大きくなった
厳密バイトの記録出力がこの測定時点で まさにこのバイト列である こと無効になる -- 新しいコンパイラは正当に異なるバイトを吐くまず出自を確認し、レビュー付きのイベントとして更新する

両方を保ち、両方を分けておくこと。上限は 安定した安全性のアサーション だ -- バンドルサイズのリグレッションから実際にユーザーを守っているのはこれであり、コンパイラの更新が数値を 0.7% 動かしたくらいで動かしてはならない。厳密バイトの記録は 情報的・記録的 なものだ -- ドリフトを可視化し、測定に日付を与えるものであり、生成者が変われば、レビューのもとで更新されることが 期待されている。記録の更新は日常の記帳だが、赤いランを緑に変えるために上限を引き上げることは ゲートの改ざん だ。

この層の位置づけ

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

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

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

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

作成更新