QR Send

スマホとPCのChrome間で、URLやテキストをQRコード1回のペアリングで送り合える自作Chrome拡張。アカウント不要・無料。

使ってみる →

見た目の崩れをtoHaveScreenshot()で止める:CIで安定するVisual Regression Testingの始め方

Selenium・自動化

CSSのライブラリを差し替えたら、見た目が崩れた。でもテストは全部成功のまま。QR Sendのランディングページを刷新したとき、私はまさにこれをやりました。ボタンのクリックもフォーム送信も、書いていたE2Eテストは全部パスしていたのに、リリース後に見に行ったらカードのレイアウトが横に飛び出していたんですね。

考えてみれば当たり前で、Playwrightのテストは「ボタンが押せるか」「文字が表示されているか」という振る舞いは見てくれますが、「レイアウトが崩れていないか」という見た目まではデフォルトでは見てくれません。1年目の頃はここに気づかず、「テストが通っていれば大丈夫」だと信じ切っていました。

見た目の崩れは`toHaveScreenshot()`で止められる

この手の事故を防ぐのがVisual Regression Testing(VRT)です。名前だけ聞くと大掛かりな仕組みが要りそうですが、Playwrightなら追加ライブラリなしで、既存のテストに1行足すだけで始められます。

test('トップページの見た目', async ({ page }) => {
  await page.goto('https://example.com');
  await expect(page).toHaveScreenshot();
});

これだけです。`toHaveScreenshot()`はページ(や要素)をスクリーンショットして、保存済みの画像と比較し、差分があればテストを落としてくれます。CSSやライブラリを差し替えたときに「どこが崩れたか分からない」まま気づかず本番に出す、という私がやらかしたパターンをここで止められます。

初回実行はベースライン生成。これは失敗ではない

ここで1年目がまずハマるのが、初回実行時の挙動です。`toHaveScreenshot()`を書いてすぐに実行すると、比較対象の画像がまだ存在しないので、こう表示されます。

Error: A snapshot doesn't exist at example.spec.ts-snapshots/example-test-1-chromium-darwin.png, writing actual.

「エラーが出た、失敗した」と焦る必要はありません。これは失敗ではなく、テストディレクトリの中に`-snapshots`フォルダが作られて、今回撮った画像がそのまま「基準(ベースライン)」として保存された、という意味です。2回目以降はこの画像と比較して、差分が出たら初めて本当に失敗します。

保存されるファイル名にも意味があります。`example-test-1-chromium-darwin.png`のように、テスト名・ブラウザ名・OS名がそのまま入っているんですね。これ、あとで見返すと「あ、このベースラインはMacで撮ったものだったんだ」とすぐ分かるので、地味に助かります。逆に言えば、OSやブラウザが変われば別のファイルとして扱われるということでもあります。次の章で触れるCIでの環境差の話にもつながってくるところです。

意図的にデザインを変更したあとにベースラインを更新したいときは、`–update-snapshots`を明示的に付けて実行します。

npx playwright test --update-snapshots

ここが地味に大事なところで、ベースラインの更新は「テストがたまたま通ってしまった」ような曖昧な形では起きません。`–update-snapshots`という人間の意思表示があって初めて更新される仕組みになっています。デザイン変更をレビューせずに黙って基準を書き換えてしまう事故を防いでくれる、良い設計だと思います。

ローカルでは通るのにCIで失敗する問題

`toHaveScreenshot()`を導入した人が次にぶつかるのがこれです。ローカルのMacで撮ったベースラインをそのままコミットしてCIに流すと、実装は何も変えていないのにCI上のテストだけ差分ありで落ちます。

原因は実装のバグではなく、ほとんどの場合環境差です。OSが変わればフォントのレンダリングやアンチエイリアスのかかり方が微妙に変わり、ピクセル単位で比較する`toHaveScreenshot()`はその差分もきっちり拾ってしまいます。macOSとUbuntu(多くのCIランナー)では、同じCSSでも文字の描画結果が違うんですね。

対策はシンプルで、ベースラインはテストを実行するのと同じ環境で作るのが原則です。具体的には、Playwright公式が配布しているDockerイメージの中でベースラインを生成し、そのままCIでもそのイメージを使ってテストを実行します。

docker run --rm -v $(pwd):/work -w /work \
  mcr.microsoft.com/playwright:v1.48.0-jammy \
  npx playwright test --update-snapshots

「ローカルのMacで見た目を確認しながら、最終的なベースラインの生成だけDocker経由でやる」くらいの運用にすると、CIで失敗する問題はかなり減ります。CI自体をまだ組んでいない方は、以前書いたGitHub ActionsでPlaywrightを自動実行する記事を先に見てもらえると、Dockerイメージの指定箇所もイメージしやすいと思います。

動くもの・変わるものはmaskで隠す

環境差以外にもう一つ、`toHaveScreenshot()`を不安定にする要因があります。日付表示・広告バナー・ユーザーのアバター画像のように、実行するたびに内容が変わる部分です。ここを何もせず撮影すると、実装もCSSも何も変えていないのに毎回差分が出てしまいます。

こういう「動く部分」は`mask`オプションで隠してしまいましょう。

await expect(page).toHaveScreenshot({
  mask: [page.locator('.today-date'), page.locator('.user-avatar')],
});

指定した要素は撮影時にピンク色の四角で塗りつぶされ、比較対象から除外されます。中身が何であれ「そこに要素がある」ことだけを確認できれば十分、という割り切りです。広告枠のように外部サービスが配信していて内容を制御できない要素も、同じくmaskで隠しておくのが無難です。「広告の中身まで一致するか」を毎回検証しても得るものがないですし、むしろノイズにしかなりません。

アニメーションも同様にノイズの元です。`toHaveScreenshot()`はデフォルトでCSSアニメーションやトランジションを無効化した状態(`animations: ‘disabled’`)で撮影してくれるので、明示的に何かを書かなくても最初から効いています。ただしJavaScriptで自前実装しているアニメーションまでは止められないことがあるので、フェードインの途中で撮ってしまい微妙に差分が出る、というケースは私も経験しました(原因の切り分けに少し時間がかかりました)。怪しいときは撮影前に`waitForTimeout`ではなく、対象要素の状態変化を`expect`で待つか、フォントの読み込み待ちも兼ねて`await page.evaluate(() => document.fonts.ready)`を挟んでから撮ると安定します。

ページ全体ではなく要素単位で撮ることもできる

ここまではページ全体を`expect(page).toHaveScreenshot()`で撮る例を見てきましたが、正直ページ全体を撮ると差分が出たときに「結局どこが崩れたのか」が分かりにくいこともあります。カード1枚、ヘッダー1個だけを検証したいなら、locatorに対して同じメソッドを呼べます。

await expect(page.locator('.pricing-card')).toHaveScreenshot('pricing-card.png');

これなら比較対象の画像が小さくなるぶん、レビュー時に差分画像を見てもひと目で「ここが崩れた」と分かります。私は新規コンポーネントを作ったときはまずこの要素単位で撮っておいて、ページ全体のVRTは主要な画面だけに絞る、という使い分けをしています。全ページを毎回フルスクリーンで撮ると差分レビューの負担も画像の保存容量も増えるので、最初から全部盛りにしない方がうまくいくと思います。

しきい値は最後の逃がし弁

ここまでやってもなお、1〜2ピクセルだけ差分が残る、というケースは正直あります。ここで焦って`maxDiffPixelRatio`を最初から緩めに設定してしまう人がいますが、これはやる順番が逆だと思っています。

await expect(page).toHaveScreenshot({
  maxDiffPixelRatio: 0.01,
});

しきい値は「テストがうるさいから黙らせる」ための道具ではありません。ベースラインを同じ環境で作り、動く部分はmaskで隠し、アニメーションも止めた。それでも残る、ごくわずかなレンダリングノイズを最後に逃がすための弁です。最初からしきい値を緩めてしまうと、本当に崩れたレイアウトまで見逃してしまいかねません。私は`maxDiffPixelRatio`を触るのを一番最後に回すようにしています。

ちなみに私は最初、既存のE2Eテストの末尾に`toHaveScreenshot()`を1行足すところから始めました。いきなり専用のVRTスイートを作ろうとすると腰が重くなりますが、「ついでに1行足す」くらいの軽さで始められるのがPlaywrightのVRTの良いところだと思っています。まずは自分のプロジェクトで一番よく変更する画面に1行足してみて、ベースラインが生成される様子を見てみてください。

まとめ

`toHaveScreenshot()`は追加ライブラリなしで始められる分、環境差やノイズへの対処を後回しにしがちです。ベースラインは同じCI環境(公式Dockerイメージ)で作る、動く部分はmaskで隠す、しきい値は最後に触る。この順番さえ守れば、「CSSを差し替えたらどこかで見た目が崩れていた」という私がやらかした事故は、かなりの割合で事前に検知できるようになるはずです。

CLIオプションをもう少し詳しく知りたい方はPlaywright CLIオプション完全リファレンス、CIでテストが不安定な場合は「たまに落ちるテスト」を勘で直さない記事もあわせてどうぞ。VRTを導入してみて詰まったところがあれば、コメントで教えてもらえるとうれしいです。

コメント

タイトルとURLをコピーしました