はじめに
とあるサンプルアプリの開発でClaude Codeとペアプロしたときに「E2Eスクリーンショットテスト」による画面情報の共有を試してみたところ、画面確認のためにブラウザを開く回数が大幅に減り、AIとのやり取りがスムーズになったので紹介します。
前提条件
この手法を試すには以下が必要です。
- Playwrightがセットアップ済み
- E2Eテストを実行できる環境(開発サーバーやEmulatorなど)
- Claude Codeがリポジトリ内のファイルを読み取れる状態
課題:AIと画面状態を共有できない
Claude Codeとペアプロする際、こんな課題がありました。
- 変更後の結果を確認するために、毎回開発環境を起動してブラウザで目視確認が必要
- 「この画面をこう変えて」と伝えるときに毎回スクリーンショットを取得して添付が必要
画面の状態をAIと共有できれば、もっとスムーズに開発できるのでは?
解決策:ビジュアルリグレッションテストの導入
最初は page.screenshot() で単純にスクリーンショットを取得・保存するだけのテストにしていましたが、それだとUIに変更がなくても差分が検出されてしまうことがありました。
そこで、もう一歩頑張ってPlaywrightの toHaveScreenshot() を使ったビジュアルリグレッションテストに切り替えました。OSによる差分の考慮等、追加の対策は必要でしたが、これにより、AIとの画面共有と意図しないUI変更の検出を両立できました。
後述するstabilizePage等の施策でスクリーンショットが安定したので、ローカルでの運用のみであればpage.screenshot()に戻しても問題ないかもしれません。ただ、せっかくなのでCIでリグレッションテストができるtoHaveScreenshot()を使う形式のままにしています。
Playwright設定
以下はスクリーンショットテストに関連する設定のみ抜粋しています。
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
expect: {
toHaveScreenshot: {
maxDiffPixelRatio: process.env.CI ? 0.05 : 0.01, // CIではOS間の差異を許容
animations: 'disabled', // アニメーションを無効化
threshold: 0.2, // 色差の閾値
scale: 'css', // デバイスピクセル比に関係なく一貫したサイズ
},
},
snapshotDir: './docs/screenshots',
snapshotPathTemplate: '{snapshotDir}/{projectName}/{arg}{ext}',
updateSnapshots: 'missing', // 新規のみ作成、差分は失敗
use: {
timezoneId: 'Asia/Tokyo', // タイムゾーン固定
},
projects: [
{ name: 'desktop', use: { ...devices['Desktop Chrome'] } },
{ name: 'mobile', use: { ...devices['iPhone 15 Pro Max'] } },
],
});
ポイント:
-
updateSnapshots: 'missing'で既存スクショは保護、差分があればテスト失敗 - 意図的に更新する場合は
--update-snapshots=allフラグを使用 -
docs/screenshots/に保存してgit管理 & AI共有 -
snapshotPathTemplateによりdocs/screenshots/{desktop|mobile}/画面名.pngの形式で保存される -
maxDiffPixelRatioはローカルでは1%(0.01)、CIでは5%(0.05)に設定。CIの閾値が高いのはOS間のレンダリング差異を吸収するため。5%だと軽微なデザイン崩れは検出できないので、CIでも厳密に差分検出したい場合はOS別にスクリーンショットを分けるのがよいかも(5%設定だとCIでの検出はローカルで確認済みの前提での保険的な位置づけ)
現状はスクショファイルを増やしたくないのと、基本ローカルでチェックできているのでこの構成にしていますが、リグレッションテストを重視する場合は、OSごとにディレクトリを分けたほうが良さそうで、実はそっちの方向と悩んでいます。
フォント安定化
環境間でフォントが異なると、同じUIでもスクショに差分が出てしまいます。これを防ぐため、テスト用フォントを注入する仕組みを用意しました。
// tests/e2e/_utils/fonts.ts
import { readFileSync, existsSync } from 'node:fs';
import { resolve, join } from 'node:path';
let cache: string | null = null;
export const getScreenshotFontCss = (): string => {
if (cache) return cache;
const dir = resolve(process.cwd(), 'node_modules/@fontsource/noto-sans-jp/files');
const fonts = [400, 500, 600, 700]
.flatMap((w) => ['japanese', 'latin'].map((s) => ({ weight: w, subset: s })))
.map(({ weight, subset }) => {
const file = join(dir, `noto-sans-jp-${subset}-${weight}-normal.woff2`);
if (!existsSync(file)) return '';
const base64 = readFileSync(file).toString('base64');
return `@font-face{font-family:'Noto Sans JP';font-weight:${weight};src:url(data:font/woff2;base64,${base64})format('woff2')}`;
})
.filter(Boolean);
cache = `${fonts.join('\n')}\n*{font-family:'Noto Sans JP',sans-serif!important}`;
return cache;
};
@fontsource/noto-sans-jp をdevDependencyとして追加し、フォントファイルをbase64でCSSに埋め込みます。
pnpm add -D @fontsource/noto-sans-jp
テストコード
// tests/e2e/screenshots.spec.ts
import { test, expect } from '@playwright/test';
import { getScreenshotFontCss } from './_utils/fonts';
import type { Page } from '@playwright/test';
const fontCss = getScreenshotFontCss();
const stabilizePage = async (page: Page): Promise<void> => {
await page.waitForLoadState('load');
await page.addStyleTag({ content: fontCss });
await page.evaluate(() => document.fonts.ready);
};
test.describe('Screenshot capture', () => {
test('Agents list', async ({ page }) => {
await page.goto('/agents');
await expect(page.getByRole('heading')).toBeVisible();
await stabilizePage(page);
await expect(page).toHaveScreenshot('agents.png', { fullPage: true });
});
test('Chat page', async ({ page }) => {
await page.goto('/agents/agent-id/chat');
await expect(page.getByRole('textbox')).toBeVisible();
await stabilizePage(page);
await expect(page).toHaveScreenshot('chat.png', { fullPage: true });
});
// ... 他の画面も同様に
});
ポイント:
-
stabilizePage()でフォント注入とロード完了を待機 -
toHaveScreenshot()で自動的に差分比較 - 差分があればテスト失敗、CI/CDでリグレッション検出可能
Claude Codeへの指示設定
スクリーンショットを自動更新していきたい場合は、CLAUDE.md に以下のように記載し、作業完了後に必ずスクリーンショットテストを実行してもらうようにすると良さそうです。
## スクリーンショットテスト
- 新しいUI(ページ/モーダル等)を追加した場合はテストケースを追加
- UIを変更した場合はスクリーンショットテストを実行:
- 明らかにUIを変更した場合は直接スナップショットを更新:
pnpm test:e2e 'tests/e2e/screenshots.spec.ts' --update-snapshots=all
- UIが変わったか不明な場合はまず確認:
pnpm test:e2e 'tests/e2e/screenshots.spec.ts'
スクリーンショットは `docs/screenshots/` に保存されます。
hook機能でテストを自動実行させることもできますが、不要なタイミングでも実行されてしまうので、CLAUDE.mdやAgent Skill等での指示にしておく方が個人的には良さそうな気がしています。
開発フロー
初回セットアップ
最初にスナップショットを生成します。updateSnapshots: 'missing' の設定により、存在しないスナップショットは自動的に作成されます。
pnpm test:e2e 'tests/e2e/screenshots.spec.ts'
日常の開発サイクル
-
Claude Codeに変更依頼 - 「
docs/screenshots/desktop/agents.pngを見て、ここにボタンを追加して」 -
コード変更後、スクショテスト実行 - 意図した変更ならば
--update-snapshots=allで更新 - 差分確認 - gitで画像の差分を確認
あなた: docs/screenshots/desktop/agents.png を見て、このリストにフィルター機能を追加して。
変更後はスクショテストを実行して結果を確認して。
Claude Code: [スクショを確認] 現在のエージェント一覧画面を確認しました。
フィルター機能を追加します...
[コード変更]
UIを変更したので --update-snapshots=all でスナップショットを更新します。
[スナップショット更新]
完了しました。新しいスクショを確認してください。
メリット
1. AIと画面状態を共有できる
リポジトリ内にスクショがあることで、都度スクショを取得しなくてもAIは現在の画面を「見て」理解できます。
2. 意図しない変更を自動検出
toHaveScreenshot() による差分比較で、修正箇所以外への影響(レイアウト崩れ等)を自動検出できます。テストが失敗することで気づけます。
3. ドキュメントにも流用可能
取得したスクショはそのままREADMEやドキュメントの素材として使えます。
実装のコツ
テストデータを事前にセットアップ
空の画面よりも、データが入った状態のスクショの方が実用的です。
test.beforeAll(async () => {
// テスト用のユーザーとデータを作成
await createUser({ email: 'user@example.com', role: 'admin' });
await createAgent({ name: 'Sample Agent', status: 'synced' });
});
ダイアログやモーダルも忘れずに
画面遷移だけでなく、ダイアログの状態もキャプチャしておくと便利です。
test('Create agent dialog', async ({ page }) => {
await page.getByRole('button', { name: '新規作成' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await stabilizePage(page);
// ダイアログだけをスクショ
await expect(page.getByRole('dialog')).toHaveScreenshot('create-dialog.png');
});
stabilizePage で安定化
フォント読み込みやレイアウト計算が完了する前にスクショを取ると、不安定な結果になります。stabilizePage() のようなヘルパーを用意しておくと便利です。
const stabilizePage = async (page: Page): Promise<void> => {
await page.waitForLoadState('load');
await page.addStyleTag({ content: fontCss }); // フォント注入
await page.evaluate(() => document.fonts.ready); // フォント読み込み待機
};
注意点
動的コンテンツへの対処
日時表示などの動的コンテンツがあると、毎回スクショが変わってしまいます。Playwrightの設定でタイムゾーンを固定する、テストデータの日時情報は固定化するなどの工夫が必要です。
// playwright.config.ts
export default defineConfig({
use: {
timezoneId: 'Asia/Tokyo',
},
});
CIでの実行
ローカルとCIでOSが異なる場合、フォントレンダリングに差異が出ることがあります。対策としては:
- フォント注入(本記事の方法)- テスト専用フォントを埋め込み
-
CI側でフォントインストール -
apt-get install fonts-noto-cjk -
OSごとにディレクトリを分ける -
snapshotPathTemplateでOS別に管理(例:'{platform}/{arg}{ext}') - CIではスキップ - ローカルのみで実行
私はローカルでのClaude Code利用がメインなので、CIのmaxDiffPixelRatioを5%と緩めに設定していますが、Claude Code Actionsをメイン利用している場合は、スクリーンショットの取得/コミット自体をCI上で行うようにして、maxDiffPixelRatioを厳しめに設定するのもありだと思います。
おわりに
E2Eスクリーンショットテストは、Claude Codeとのペアプロを効率化するのに有効な手法でした。
特に効果を感じたのは以下の点です。
- 画面確認の手間が減った - ブラウザを開かずにAIとのやり取りだけで画面の状態を共有できる
-
意図しない変更に気づけた -
toHaveScreenshot()による差分検出で、修正箇所以外への影響も自動検出
AIエージェントを使った開発をしている方は、ぜひ試してみてください。
参考
本記事で紹介したテストコードは、別記事で公開しているサンプルアプリのリポジトリで確認できます。
この記事はZenn/Qiitaにクロスポストしています