CSSを直した後、ボタンは押せるのにカードの余白が崩れていた。こうした見た目の変化を拾うには、PlaywrightのtoHaveScreenshot()で、確認済みの基準画像と、現在の画面を比較します。
ただし、失敗するたびに基準画像を上書きすると、崩れた状態を正解にしてしまいます。ここでは小さな画面を用意し、基準を作る、余白の変更を検出する、確認した変更だけを新しい基準にする、という順に進めます。
実行確認した環境はPlaywright 1.61.1、同バージョンに対応するChromium Headless Shell 149.0.7827.55、Node.js 24.13.0、macOSのApple Silicon環境です。1440pxと390pxの2つの画面幅で比較しています。
画面幅と比較条件を固定する
まず、検証用の空のディレクトリで依存を入れます。既存プロジェクトへ入れる場合は、すでにあるPlaywright設定へ必要な項目を統合してください。
npm init -y
npm install -D --save-exact @playwright/test@1.61.1
npx playwright install chromium
mkdir tests
playwright.config.tsを次の内容で作ります。この例では基準の更新を通常実行から切り離すため、updateSnapshots: 'none'を指定しています。
import { defineConfig } from '@playwright/test';
const artifacts = process.env.PW_ARTIFACT_DIR || '.tmp-preview';
export default defineConfig({
testDir: './tests',
updateSnapshots: 'none',
snapshotPathTemplate: '{testDir}/__screenshots__/{projectName}/{platform}/{testFilePath}/{arg}{ext}',
outputDir: `${artifacts}/results`,
reporter: [['list'], ['html', {
outputFolder: `${artifacts}/report`,
open: 'never',
}]],
workers: 1,
retries: 0,
expect: {
timeout: 5000,
toHaveScreenshot: {
animations: 'disabled',
caret: 'hide',
scale: 'css',
threshold: 0.2,
maxDiffPixels: 0,
},
},
use: {
baseURL: 'http://127.0.0.1:5207',
browserName: 'chromium',
deviceScaleFactor: 1,
colorScheme: 'light',
locale: 'en-US',
timezoneId: 'UTC',
trace: 'retain-on-failure',
},
projects: [
{ name: 'chromium-desktop', use: { viewport: { width: 1440, height: 900 } } },
{ name: 'chromium-mobile', use: { viewport: { width: 390, height: 844 } } },
],
webServer: {
command: 'node server.mjs',
url: 'http://127.0.0.1:5207',
reuseExistingServer: false,
timeout: 10000,
},
});
同じテストをdesktopとmobileの2つのprojectで実行します。mobileは390px幅のChromiumであり、iPhone実機やSafariを再現する設定ではありません。基準画像の保存先にはproject名とOS名を含め、違う条件の画像が混ざらないようにしています。
失敗時の画像やHTMLレポートは、既定でこのプロジェクトの.tmp-preview/へ保存します。保存先を変える場合はPW_ARTIFACT_DIRで指定できます。基準画像のtests/__screenshots__/は、これとは別にGit管理します。
Playwrightのバージョンだけでなく、OS・フォント・ブラウザ・描画条件も画像に影響します。基準を作る環境と比較する環境を揃える点は、公式の画像比較ガイドでも注意されています。保存先にOS名を入れても、同じOSでのフォントの違いや、OS更新による描画の差は残ります。
データが揃った後に撮影し、時刻だけをmaskする
tests/board.spec.tsを作ります。APIの返答を固定し、「Ready」と3枚のカードが表示された後に比較します。通信が終わるまで適当な秒数を待つ方法より、撮りたい画面状態ができたかを条件にすると、読み込み途中を基準にしにくくなります。
import { test, expect } from '@playwright/test';
test('board ready', async ({ page }) => {
await page.route('**/api/cards', route => route.fulfill({
json: { cards: ['Check inventory', 'Review shipment dates', 'Prepare the report'] },
}));
await page.goto('/');
await expect(page.getByRole('status')).toHaveText('Ready');
await expect(page.getByRole('list', { name: 'Tasks' }).getByRole('listitem')).toHaveCount(3);
await page.evaluate(() => document.fonts.ready);
await expect(page).toHaveScreenshot('board-ready.png', {
fullPage: true,
mask: [page.getByTestId('clock')],
});
});
maskに渡すのは、そのページ上のLocatorです。そのため、この例では共通設定の中ではなく、テスト内のtoHaveScreenshot()へ渡しています。時刻の文字だけが毎回変わるので、時計の領域を比較画像上で塗りつぶします。
document.fonts.readyでフォントの読み込みを待っていますが、実サイトの遅延画像や後から表示する部品まで、この一行ですべて読み込めるわけではありません。必要な画像の読み込みが終わり、見出しや件数が表示されたことを確認してから撮ります。
試す画面:index.html
次の画面は、3件の作業カードをAPIから読み込み、上部に現在時刻を表示します。外部サービスやログイン情報を使わず、この一式だけで実行できます。
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Workboard</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; background: #edf2f7; color: #172b4d; font: 16px/1.6 Arial, sans-serif; }
main { max-width: 1000px; margin: auto; padding: 40px 20px; }
h1 { margin: 0; font-size: 32px; }
header { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
time { display: inline-block; width: 12ch; font-variant-numeric: tabular-nums; }
.cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; padding: 0; list-style: none; }
.card { padding: 24px; border: 1px solid #cbd5e1; border-radius: 12px; background: white; }
.card h2 { margin: 0 0 16px; font-size: 20px; }
button { border: 0; border-radius: 6px; padding: 10px 16px; background: #7c3aed; color: white; font: inherit; }
@media (max-width: 600px) {
header { display: block; }
.cards { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main>
<header><h1>Workboard</h1><time data-testid="clock"></time></header>
<p>Three tasks for the next handover.</p>
<ul class="cards" aria-label="Tasks"></ul>
<p role="status">Loading tasks...</p>
</main>
<script type="module">
const clock = document.querySelector('[data-testid="clock"]');
const tick = () => { clock.textContent = new Date().toISOString().slice(11, 19); };
tick(); setInterval(tick, 1000);
try {
const response = await fetch('/api/cards');
if (!response.ok) throw new Error('Cannot load tasks');
const data = await response.json();
const list = document.querySelector('.cards');
for (const title of data.cards) {
const item = document.createElement('li');
item.className = 'card';
const heading = document.createElement('h2');
heading.textContent = title;
const button = document.createElement('button');
button.textContent = 'Mark done';
button.addEventListener('click', () => { button.textContent = 'Done'; button.disabled = true; });
item.append(heading, button); list.append(item);
}
document.querySelector('[role="status"]').textContent = 'Ready';
} catch {
document.querySelector('[role="status"]').textContent = 'Could not load tasks';
}
</script>
</body>
</html>
ローカルサーバー:server.mjs
同じディレクトリにserver.mjsを作ります。Playwrightがテスト前に起動するため、別ターミナルで起動しておく必要はありません。5207番ポートを使う別のサーバーがある場合は、設定とサーバーの両方を空いている番号へ揃えます。
import http from 'node:http';
import { readFile } from 'node:fs/promises';
const fixture = { cards: ['Check inventory', 'Review shipment dates', 'Prepare the report'] };
const server = http.createServer(async (req, res) => {
const pathname = new URL(req.url, 'http://127.0.0.1').pathname;
if (pathname === '/api/cards') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(fixture));
return;
}
if (pathname === '/') {
try {
const body = await readFile(new URL('./index.html', import.meta.url));
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(body);
} catch {
res.writeHead(500); res.end('Cannot load fixture');
}
return;
}
res.writeHead(404); res.end('Not found');
});
server.listen(5207, '127.0.0.1');
APIの固定値は、テスト用の画面状態を作るためのものです。本物のAPIが正しく動くかを確かめるテストとは分けます。また、画像が同じでもボタンのクリック処理が壊れている場合があるので、必要な操作は別途アサーションで確認します。
基準を作ったら、更新オプションなしで比較する
まず通常実行します。
npx playwright test tests/board.spec.ts
基準がまだなければ、今回の設定ではA snapshot doesn't exist...として失敗し、基準を勝手に作りません。最初の画像を作るときだけ、対象ファイルを指定して実行します。
npx playwright test tests/board.spec.ts --update-snapshots=missing
検証した1.61.1では、このコマンドで基準画像が生成されました。ただし、テスト自体はwriting actualを表示して失敗しました。画像ができた時点では、まだ比較に成功していません。生成された2枚を開き、読み込み中の表示や意図しない崩れがないかを見てください。
tests/__screenshots__/
chromium-desktop/darwin/board.spec.ts/board-ready.png
chromium-mobile/darwin/board.spec.ts/board-ready.png
darwinは今回のmacOS環境の値です。Linuxならlinuxになり、同じPNGをそのまま共有する前提にはしません。画像を確認できたら、もう一度、更新オプションなしで実行します。
npx playwright test tests/board.spec.ts
検証では2件とも成功しました。基準画像はテストコード・設定・lockfileと一緒にGitへ追加します。失敗時のactual・diff・traceやHTMLレポートを、基準画像として追加しないようにしてください。
崩れを入れて、expected・actual・diffを見比べる
index.htmlのカードの余白を、試しに24pxから40pxへ変えます。
.card { padding: 40px; /* 検証用の変更。ほかの指定は元のまま */ }
同じテストを通常実行すると、今回の検証ではdesktopで19214px、mobileで26249pxの差分を検出しました。差分数はこの画面・環境の実測値であり、別の環境で同じ数になるとは限りません。
| 画像 | 確認すること |
|---|---|
| expected | Gitに保存した、確認済みの基準 |
| actual | 今回撮影した画面。文字の折り返しや余白を読む |
| diff | 差がある場所の手掛かり。これだけで正常・異常を判定しない |
HTMLレポートは次のコマンドで開けます。PW_ARTIFACT_DIRを変更した場合は、実際の保存先へ置き換えてください。
npx playwright show-report .tmp-preview/report
意図しない余白の変更なら、基準を更新せずCSSを24pxへ戻します。検証でも基準画像のSHAが変わっていないことを確認し、CSSを戻した後に2件とも成功しました。原因となるCSSが分からない場合は、CSSが効かないときの確認順で、読み込みや適用された指定を調べます。
maxDiffPixelsが0でも、完全な色一致とは限らない
thresholdとmaxDiffPixelsは、別の条件です。
| 設定 | 役割 | 今回の値 |
|---|---|---|
threshold |
同じ位置のピクセルの色差を、どこまで許容するか。0〜1で、小さいほど厳しい | 0.2 |
maxDiffPixels |
差があると判定されたピクセルを何個まで許容するか | 0 |
animations |
撮影時のCSSアニメーション・トランジション等の扱い | disabled |
mask |
指定した要素の領域を撮影画像で覆う | 時計だけ |
実際に、初期の検証画面のボタン色を#164e63から近い#065f46へ変えたときは、maxDiffPixels: 0でも2件とも成功しました。threshold: 0.2の色差判定を通ったためです。重要なブランド色を厳密に確認したいなら、その要素のCSS値の確認も組み合わせます。失敗を減らすためだけにthresholdや許容ピクセル数を大きくしないでください。
threshold: 0.2は「画像の20%が変わってもよい」という意味ではありません。割合の上限を指定する別の項目にはmaxDiffPixelRatioがあります。各設定の定義はtoHaveScreenshotのAPIリファレンスで確認できます。
また、maskしても要素の大きさや位置が変われば、塗りつぶした領域や周囲の配置が変わります。何でもmaskするのではなく、まず日付や一覧データを固定し、それでも比較したくない部分だけを選びます。animations: 'disabled'も、JavaScriptのタイマーやネットワーク更新を止める設定ではありません。
意図した変更だけを更新し、CIでも同じ基準を使う
デザイン変更として採用する差なら、actualとdiffを確認した後に基準を更新します。検証ではボタンを#164e63から紫の#7c3aedへ変えたところ、desktopで12835px、mobileで13113pxの差分が出ました。掲載したindex.htmlでは、この紫を採用しています。
まずmobileの画像だけを確認したなら、対象を絞って更新します。
npx playwright test tests/board.spec.ts --project=chromium-mobile --update-snapshots=changed
検証ではmobileの基準だけが変わり、desktopのSHAは変わりませんでした。desktopも確認してから同様に更新し、最後に両方を通常実行します。
npx playwright test tests/board.spec.ts --project=chromium-desktop --update-snapshots=changed
npx playwright test tests/board.spec.ts
最後の通常実行では2件とも成功しました。CLIの更新オプションは、設定ファイルのupdateSnapshots: 'none'より優先されます。通常のCIでは更新フラグを付けず、画像がない・差がある場合は失敗させます。更新モードの違いはTestConfigの説明を参照してください。
| 保管するもの | 置き場所と扱い |
|---|---|
| 確認済みの基準PNG | テストコードと同じGitリポジトリ。PRで変更理由と画像差分を確認 |
| 失敗時のactual・diff・HTMLレポート | CIの実行成果物。原因確認に使い、保存期間を決める |
| OS・ブラウザ・フォントの条件 | CI設定や環境構築ファイル。基準を作る環境も同じ条件に揃える |
| 基準更新の操作 | 対象ファイル・projectを指定した確認用の実行。通常CIへ自動更新を組み込まない |
CIがLinuxなら、そのLinux環境で作成・確認した基準が必要です。macOSで作った画像をCIへ持っていって毎回差が出る場合は、許容値を増やす前に環境を揃えます。Playwrightと対応ブラウザの更新時も、まとめて基準を作り直すだけで済ませず、差分を確認する変更として扱います。ここで実測したのはmacOSでの比較で、CI上の実行は別途確認が必要です。
最初の対象は、変更が多い画面の「データ読み込み後」のような一つの状態に絞ると管理しやすくなります。ページ、データ、画面幅、mask対象、基準作成環境、変更を確認する担当を記録し、同じ条件で比較してください。操作テストを変更後に動かす仕組みは、PlaywrightとClaude Code Hooksの連携でも紹介しています。