きっかけは、共通のカラー変数を一つ触っただけのPRでした。
テストは全部緑。ビルドも通る。Lighthouseも95点のまま。なのにプレビューを開いたら、トップの見出しが1行ぶんだけ下にずれていて、ファーストビューの収まりが微妙に崩れていた。レビューでは誰も気づかず、マージ後に自分が偶然スマホで開いて発見した、という間の悪さでした。
正直なところ、この手の「動いてはいるが見た目だけ壊れている」変更を、私たちのテストは一件も検知できていませんでした。単体テストは関数の入出力を見るし、Lighthouseはスコアを見るし、axeはアクセシビリティ違反を見る。でも「見出しの位置が1行ずれた」は、そのどれの守備範囲でもないんですよね。
以前、Tailwind CSS v4への移行記事の最後に「ちゃんとしたビジュアルリグレッションテストを入れておくべきだった」と反省を書いたことがあります。その回収を、いい加減やることにしました。
動作は壊れていない、という盲点
テストは目的ではなく手段です。私たちが本当に守りたいのは「ユーザーが見る画面が、意図した通りであること」で、単体テストやE2Eはそのための手段の一つでしかない。ところが手段のほうが目的化していて、「テストが緑=品質OK」という空気になっていました。
動いた、は品質OKではない。ここをもう一度はっきりさせておきます。
自社サイトはAstroの静的サイトで、セクション単位にスコープを切ったCSSと、全ページ共通の theme.css(カラー変数・タイポグラフィ・余白トークン)で見た目を組んでいます。この構造は普段は快適なんですが、共通トークンを一つ触ると全ページに波及するという弱点がある。1ページ直したつもりが、実は数十ページに影響していた、というのが今回の事故の構造でした。
機能テストが見ている世界と、ユーザーが見ている世界の間に、これだけの隙間がある。整理するとこうです。
| 検査 | 拾える | 拾えない |
|---|---|---|
| 単体テスト | 関数・コンポーネントの入出力 | 描画結果の見た目 |
| Lighthouse | 性能・CLS・SEOのスコア | スコアに出ない位置・色のズレ |
| axe / a11y | コントラスト・ラベル・aria | 意図しないレイアウト変化 |
| ビジュアルリグレッション | 前回との見た目の差分そのもの | 「その変化が正しいか」の判断(人が要る) |
一番下の行が今回入れたものです。ビジュアルリグレッションテスト(VRT)は、ページを画像として撮っておいて、次の変更後に撮った画像と1ピクセル単位で比較し、差分が出たら知らせる、というだけのシンプルな仕組みです。差分が「正しい変更なのか事故なのか」を判断するのは人間の仕事で、そこは自動化しません。
静的サイト × プレビュー環境は、VRTと相性がいい
VRTで一番やっかいなのは「撮るたびに絵が変わる」ことです。ログイン状態、時刻表示、ランダムなおすすめ枠、A/Bテスト。動的なページほど、毎回どこかしら差分が出てフレーキーになる。
その点、私たちのサイトはビルドで固まった静的HTMLをS3に置いてCloudFrontで配るだけなので、同じコミットからは毎回まったく同じ絵が出ます。しかもPRごとに preview.xecin.jp/pr-{番号}/ という独立したプレビュー環境が立ち上がる運用になっている。撮影対象のURLが安定していて、出力が決定的。VRTにとってこれ以上ない土俵でした。
とはいえ、最初にやらかしました。「全ページ撮っておけば安心だろう」と、サイトマップから拾った全URLを撮影対象にしたんです。結果、実行は重いわ、ニュース記事が1本増えるたびに一覧ページの差分が出て毎回赤くなるわで、二日で形骸化しかけました。
測れないものは改善できない、けれど、測りすぎても運用が回らない。撮影対象は「代表ページ+崩れやすいテンプレ」に絞り直しました。
- トップページ(共通トークンの影響を最も受ける)
- ニュース一覧(カード崩れが出やすい)
- ニュース記事テンプレ(本文・表・コードブロックの回帰を1本で代表させる)
- お問い合わせフォーム(入力要素のレイアウト崩れ)
7ページまで絞ったら、実行時間は全ページ時の1/5以下になり、記事追加で誤検知することもなくなりました。各ページをモバイル(375px)・タブレット(768px)・PC(1280px)の3幅で撮るので、実質のスナップショットは21枚。これくらいなら1回の実行が1分台に収まります。
テスト自体はPlaywright標準の toHaveScreenshot() で書きました。外部SaaSを使わず、まずはリポジトリ内で完結させる方針です。
// tests/vrt/pages.spec.ts
import { test, expect } from '@playwright/test';
// 代表ページだけに絞る。全ページは撮らない(コストとフレーキーの温床)
const pages = [
{ name: 'home', path: '/' },
{ name: 'news-index', path: '/news/' },
{ name: 'news-article', path: '/news/2026-07-19-tech-mutation-testing-test-quality/' },
{ name: 'contact', path: '/contact/' },
];
for (const p of pages) {
test(`visual: ${p.name}`, async ({ page }) => {
await page.goto(p.path);
// フォント読み込み完了まで待つ(後述のフレーキー対策)
await page.evaluate(() => document.fonts.ready);
await expect(page).toHaveScreenshot(`${p.name}.png`, {
fullPage: true,
animations: 'disabled',
// 日付やアクセス数など、変わって当然の要素は最初から比較対象から外す
mask: [page.locator('[data-vrt-mask]')],
});
});
}
ビューポートは playwright.config.ts 側で3つのプロジェクトとして持たせて、1回の実行で3幅ぶんを回すようにしています。
フレーキーは「入れてから」が本番
VRTは、導入した瞬間から差分との戦いが始まります。しかもその差分の大半は、コードの事故ではなく「撮影条件のブレ」なんですよね。ここを潰しきれないと、赤信号に慣れてしまって誰もレポートを見なくなる。オオカミ少年になった瞬間にVRTは死にます。
一番派手にハマったのが、レンダリング環境の差でした。手元のMacで作ったベースライン画像に対して、CI(Linux)で撮った画像が、7ページ×3幅の全部で差分判定になったんです。原因はフォントのアンチエイリアスの差で、文字の縁が1〜2ピクセル滲むだけ。人間の目には同じに見えるのに、ピクセル比較は容赦なく赤にする。
これは「しきい値を緩めて誤魔化す」方向に逃げると、本物の崩れも一緒に見逃すようになるので筋が悪い。結論としては、ベースライン画像は必ずCIと同じ環境(Dockerの mcr.microsoft.com/playwright イメージ)で生成する、というルールに倒しました。ローカルはあくまで開発用で、正のベースラインはCIが撮ったものだけを採用する。
原因と対策を、実際にぶつかった順に並べておきます。
| フレーキーの原因 | 対策 |
|---|---|
| ローカルとCIでフォント描画が違う | ベースラインはCIと同じDocker環境でのみ生成する |
| Webフォント未読込のまま撮影 | document.fonts.ready を待ってから撮る |
| アニメーション・トランジション途中で撮影 | animations: ‘disabled’ で停止させる |
| 日付・件数など変わって当然の要素 | mask で対象要素を比較から除外する |
| サブピクセルの微小なにじみ | maxDiffPixelRatio を小さめに設定し、緩めすぎない |
しきい値は「甘くして楽をするための旋回」ではなく、「サブピクセルの誤差だけを吸収する最小限」に置くのが肝です。うちは maxDiffPixelRatio: 0.002(全体の0.2%まで)から始めて、誤検知が出るたびに緩めるのではなく、まず撮影条件を疑うようにしています。ドキュメントには「thresholdで調整」と書いてあるけど、そこを最初に触るのは大抵悪手なんですよね。
// playwright.config.ts(抜粋)
export default defineConfig({
expect: {
toHaveScreenshot: {
maxDiffPixelRatio: 0.002, // まず条件を疑う。ここを安易に緩めない
animations: 'disabled',
},
},
// CIと同じ土俵で撮るためのプロジェクト定義
projects: [
{ name: 'mobile', use: { viewport: { width: 375, height: 812 } } },
{ name: 'tablet', use: { viewport: { width: 768, height: 1024 } } },
{ name: 'desktop', use: { viewport: { width: 1280, height: 800 } } },
],
});
ベースラインは「勝手に更新させない」
VRTで一番事故りやすいのは、実は撮影ではなく承認のほうです。差分が出たときに、確認もせず --update-snapshots で全部ベースラインを上書きしてしまうと、崩れた画面が「正」として記録される。これをやると、VRTがあるのに崩れを追認する装置になってしまう。
なので運用ルールをこう決めました。
- 意図した見た目の変更のときだけ、ベースラインを更新する
- 更新はローカルで気軽にやらず、CIが撮り直した画像をPRに差分として出し、人間が見てから承認する
- 差分レポート(before/after を並べた画像)をPRのプレビューコメントに貼り、レビュアーが必ず目視する
受け入れ基準(Acceptance Criteria)としては、こう言語化しています。「VRTで差分が出たPRは、差分が意図通りであることをレビュアーが明記しない限りマージ不可。差分ゼロか、差分の承認コメントがあること」。動いた、では通さない。見た目が意図通りだ、と誰かが言えて初めて通す。
CI側は、PRのたびにプレビュー環境が立ったあとにVRTを回す構成です。ここはまだ発展途上なので、正直に注釈を付けておきます。
# .github/workflows/vrt.yml(抜粋・イメージ)
# ※ 改善の余地あり:現状はベースライン更新をラベル(vrt-approve)の手運用に
# 依存している。意図した変更かどうかの最終判断が属人的で、
# 本来はビジュアル差分レビューを必須ゲート化したい(次の課題)。
vrt:
needs: deploy-preview
runs-on: ubuntu-latest
container: mcr.microsoft.com/playwright:v1.48.0 # CIと同じ土俵で撮る
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx playwright test tests/vrt --project=mobile --project=tablet --project=desktop
- name: 差分をPRに添付
if: failure()
uses: actions/upload-artifact@v4
with:
name: vrt-diff
path: test-results/
ツール選定も少し悩みました。Playwright標準は依存が増えず手軽ですが、ベースライン画像をリポジトリに抱えるので、変更のたびにPNGのdiffがコミット履歴に載る。画像の保管とPRコメント連携まで面倒を見たいなら reg-suit(S3にベースラインを置き、git-hashで親コミットと比較してPRに通知)、レビューUIやAI差分検知まで欲しいなら Percy のようなSaaS、という切り分けです。うちは撮影ページが少なく静的なので、まずは標準機能で十分と判断しました。ここは好みが分かれるところですが、規模が小さいうちに重い基盤を入れると運用が続かない、というのが経験則です。
これからやりたいこと
導入から3週間で、共通トークン変更のPRで2件、実際に「意図しない崩れ」を検知できました。どちらもテストは緑・Lighthouseも据え置きで、VRTがなければそのまま本番に出ていた類のものです。手段が一つ増えたぶん、レビューで「見た目、確認した?」と口頭で確認する不確実なやり取りが減ったのも地味に効いています。
一方で、撮影しているのは今も7ページだけで、ランディングページ群や実績ページはカバーできていません。全部撮ると運用が破綻するのは学んだので、次は「変更されたコンポーネントを含むページだけ撮る」ような、影響範囲ベースの撮影に寄せていきたい。ベースライン承認の必須ゲート化と合わせて、そこが当面の宿題です。
見た目の品質も、測れる形にしておかないと改善できない。VRTはその最初の一歩として、思っていたよりずっと素直に効いてくれました。