コンテンツへスキップ

ビジュアルリグレッションテストとは何か — スクリーンショット差分で崩れを検知する仕組み

WordPressのプラグインやテーマを更新したとき、PHPエラーも出ず管理画面上は「成功」と表示されるのに、実際にサイトを開くとレイアウトだけが崩れている——というケースがある。CSSの読み込み順が変わった、フォントの指定が上書きされた、特定のクラス名が衝突した、といった原因で見た目だけが静かに壊れるパターンは、エラーログには一切残らないため気づかれにくい。この「エラーは出ないが見た目は壊れている」状態を機械的に検出するのが、更新前後のスクリーンショットを比較する仕組み、いわゆるビジュアルリグレッションテストである。

補足: リグレッション(regression)とは「後退・退行」の意味で、ソフトウェアの世界では「以前は正しく動いていたものが、何らかの変更によって壊れること」を指す。ビジュアルリグレッションテストは、その壊れ方を画面の見た目(ビジュアル)の観点で検出するテスト手法。

素朴なピクセル完全一致比較がうまくいかない理由

「更新前後の画像を比較して崩れを検出する」と聞くと、単純に2枚の画像のピクセルを1つずつ突き合わせて、1ピクセルでも違えば「変化あり」と判定すればよいように思えるかもしれない。しかし実際にこの方式を試すと、ほぼ確実に問題が起きる。

同じページを2回撮影しただけでも、フォントのサブピクセルレンダリングの揺れ、アンチエイリアスのかかり方のわずかな差、ブラウザの描画タイミングによる数ピクセルのズレなど、コンテンツとしては何も変わっていないのに画素レベルでは必ず差が出る。さらにブログの最新記事一覧・広告・日時表示のような動的コンテンツを含むページでは、そもそも表示内容自体が撮影のたびに変わる。ピクセル完全一致を基準にすると、こうした「意味のない差分」まで毎回「変化あり」として検出してしまい、結果として警告が鳴りっぱなしになって誰も見なくなる——という、監視の仕組みそのものが形骸化する事態を招く。

「許容差」と「閾値」の二段構えで意味のある変化だけを拾う

core/visual_compare.pycompare_screenshots() は、この問題に対して二段階の緩和策を組み合わせている。

まず比較の前処理として、両方の画像を同じ解像度(1280×800)にリサイズし、グレースケールに変換した上で、軽いぼかしフィルター(ImageFilter.SMOOTH)をかける。色情報を落として明暗のパターンだけに注目することで色調の違いによるノイズを排除し、ぼかしによってフォントレンダリングやアンチエイリアスの数ピクセル単位の揺れを吸収する。

その上で、判定は次の2段階になっている。

  1. ピクセル単位の許容差(_PIXEL_TOLERANCE = 25: 2枚の画像の対応するピクセル同士の明度差が、0〜255の範囲で25を超えない限り「そのピクセルは変化していない」とみなす。わずかな描画の揺れをここで切り捨てる。
  2. 画面全体に対する変化率の閾値(デフォルト DEFAULT_LAYOUT_THRESHOLD = 8.0、つまり8%): 許容差を超えたピクセルの数を画面全体のピクセル数で割った変化率が、この閾値を超えたときだけ「レイアウト変化を検出」と判定する。

閾値が8%とかなり高めに設定されているのは、意図的な設計判断である。動的に内容が変わるバナーや最新記事一覧のようなセクションがページ内の一部を占めていても、その部分だけの変化ではこの閾値を超えないように余裕を持たせてある。逆に、レイアウト崩れのように画面の広い範囲に影響する変化が起きたときには、この閾値を上回ってはじめて警告として意味を持つ。「小さな差分は無視し、大きな差分だけを拾う」というのがこの二段構えの狙いである。

サイト名を変更すると「比較不能」になっていた設計の穴

このモジュールには、実装当初に踏んだ具体的な事故の教訓が反映されている。当初、更新前スクリーンショットのファイル名はサイトの表示名(site_name)をもとに before_{safe_name}.jpg という形式で保存されていた。ところが、サイトの表示名は運用中にいつでも変更されうる項目である。表示名を変更すると、保存されているファイル名と新しい表示名から導出されるファイル名が食い違い、せっかく撮影しておいた「更新前」の画像を二度と見つけられなくなる——つまり比較そのものが成立しなくなる、という不具合につながっていた。

対処として、ファイル名の基準を「表示名」から、各サイトに割り当てられ表示名を変更しても変わらない内部的な識別子(UUID形式の _id)に切り替えた。take_before_screenshot()_id が有効な形式(site_paths.is_valid_site_id() で判定)であればそちらを使い、_id が未設定の古いデータに対してのみ、従来どおり表示名ベースのパスにフォールバックする。この変更以降は、サイトの表示名を何度変更しても、同じ _id を経由して同じ画像ファイルに辿り着けるようになっている。

これは視覚比較固有の話にとどまらず、「ユーザーが自由に変更できる表示用の名前」と「システム内部で永続的に対象を特定するための識別子」を混同すると、名前を変えた瞬間に紐付けが切れるという、もう少し一般的な設計上の教訓でもある。ファイル名やキャッシュキーを何かに紐付けるときは、その紐付け先が将来変わりうる値かどうかを意識しておく必要がある。

ブラウザ撮影という「テストしにくい部分」をどう切り離すか

もう一つ興味深いのは、このモジュールに対するテスト(tests/test_visual_compare_paths.py)の設計方針である。実際のスクリーンショット撮影はPlaywrightで本物のヘッドレスブラウザを起動して行うため、決定的(deterministic)なユニットテストの対象にはしにくい。ネットワーク状態やレンダリング結果に依存する処理を毎回同じ結果になるようにテストするのは難易度が高く、モックで代替してもブラウザの実挙動を正しく再現できているかを保証しにくい。

そこでこのテストスイートは、撮影処理そのものをテスト対象にすることをあきらめ、代わりに「撮影結果のファイルをどう探し出すか」という決定的なロジックだけを切り出してテストしている。具体的には、有効な _id があるときに正しいパスを組み立てられるか、_id が無効な形式のときにレガシー形式へフォールバックするか、_id 形式とレガシー形式のファイルが両方存在するときに _id 形式を優先するか、といった分岐を、JPEGのマジックバイトと識別用の文字列だけを詰めた偽のファイルを用意して検証する。本物の画像やブラウザを一切使わずに、パスの組み立てとファイル探索という「予測可能な部分」だけを機械的に保証している。

外部要因(ブラウザ・ネットワーク・実サイトの表示内容)に左右される処理と、内部ロジックだけで完結する処理を分離しておけば、後者だけでもテストで固めておくことができる、という切り分け方の一例といえる。

エラー時の振る舞い:例外を投げずに構造化した結果を返す

compare_screenshots() は、比較前後の画像ファイルが存在しない場合や、画像処理ライブラリ(Pillow)が未インストールの場合でも、例外を送出せずに changed / diff_pct / error を持つ辞書を返す設計になっている。呼び出し側はこの辞書を見て「比較できなかったので警告なしとして扱う」といった判断を行える。ビジュアル比較はメンテナンス処理全体の中の付随的なチェックであり、この処理自体が失敗したことが原因でメンテナンス作業全体を止めてしまっては本末転倒になるため、失敗を握りつぶさずに情報として返しつつ、処理は継続させる作りになっている。

まとめ

論点 採用されている考え方
ピクセル完全一致比較 フォント描画やアンチエイリアスの揺れで誤検知が多発するため不採用
ピクセル単位の許容差 明度差が一定値を超えないピクセルは「変化なし」とみなして誤検知を吸収
画面全体の変化率閾値 一定割合を超えたときだけ警告。動的コンテンツの変動を許容する余裕を持たせて高めに設定
ファイル名の識別子 変更されうる表示名ではなく、不変の内部ID(UUID)を基準にして紐付けの喪失を防止
テスト設計 ブラウザ撮影という非決定的な処理は対象外とし、パス組み立て・ファイル探索という決定的なロジックだけを検証

画面の見た目という定量化しにくい対象を自動チェックの対象にするときは、「何をもって変化とみなすか」の閾値設計と、「何を不変の基準にして紐付けるか」という識別子設計の両方が肝になる。どちらも一度事故を踏んでから初めて重要性に気づきやすい部分であり、あらかじめ余裕を持たせた閾値設計と、表示名に依存しない識別子設計をセットで考えておく価値がある。

この「識別子設計」や「決定的な部分だけを切り出してテストする」という考え方は、より一般的な「テストの種類」の話にもつながる。関連記事: ユニットテストとリグレッションテストの違い — なぜ同じ機能に何度もテストを書くのかでは、本稿の `test_visual_compare_paths.py` のような回帰防止テストと、機能仕様を確認する通常のユニットテストとの違いを整理している。