個人で複数のアプリを長く運営してきた中で、何度も同じ後悔をしてきました。「リリース前に E2E テストを書いておけば、この本番障害は防げた」というやつです。とはいえ、テストコードの記述と保守は重く、私のような個人開発者がプロダクションコードと同等の工数を割くのは現実的ではありません。
そんな中で、Claude Code を Playwright と組み合わせ始めてから、ようやく E2E テストが「コストではなく投資」として回り始めました。本稿は、Dolice Labs の4サイト (Claude Lab・Gemini Lab・Antigravity Lab・Rork Lab) と、別フォルダで運営している Lacrima・Mystery を合わせた合計6サイトに、この構成を段階的に展開してきた中で計測した数値と、公式ドキュメントには書かれていなかった落とし穴を整理したものです。
最初に結論を共有しておきます。導入から90日で計測できた変化は次の3つです。
- フレーキーテスト率: 15.3% → 0.42% (収束値)
- Page Object クラスの新規生成時間: 約4時間 → 約18分 (1ファイル当たり、約13倍)
- 月あたりの「本番デプロイ前に止められた」障害: 0件 → 7件 (4ヶ月平均)
この数字を出すまでの過程で、私が踏んだ落とし穴と、個人開発者として E2E テストに投資すべきかを判断する基準を、本稿の後半で具体的にお伝えします。
Playwright環境構築とClaude Code連携の基礎
Playwrightはマイクロソフト開発のブラウザ自動化ツールで、Chrome・Firefox・Safari の3つのエンジンをサポートしており、モダンなWebアプリケーションのテストに最適です。Claude Codeとの連携を効果的にするため、まずは環境構築から始めましょう。
Playwrightのセットアップと初期構成
npm install --save-dev @playwright/test
npx playwright install
# playwright.config.ts の基本構成
プロジェクトに Playwright をインストールした後、playwright.config.ts ファイルを作成します。このファイルがテスト実行全体の設定ハブとなります。
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { outputFolder: 'playwright-report' }],
['json', { outputFile: 'test-results/results.json' }],
['junit', { outputFile: 'test-results/junit.xml' }],
],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
このような基本設定があれば、Claude Codeは以降のテストコード生成時に設定を参考にして、最適なテストスイートを構築できます。
Claude Codeプロンプトテンプレートの準備
Claude Codeとの効率的な連携を実現するため、再利用可能なプロンプトテンプレートを用意しておく点が肝心です。
# Playwrightテストコード生成プロンプト
あなたはPlaywright専門家です。以下の要件に基づいてテストコードを生成してください。
## 要件
- テスト対象: [UIコンポーネント/ページ]
- テストシナリオ:
1. [シナリオ1]
2. [シナリオ2]
## 約束事
- Page Object パターンを採用する
- 各要素選択にはdata-testidを使う
- wait戦略は明示的に記述する(hardcoded sleepは禁止)
- 多言語対応が必要な場合はローカライゼーション対応を含める
- accessibility(a11y)チェックを含める
## 出力形式
- ファイルパス: tests/e2e/[name].spec.ts
- Page Objectファイル: tests/pages/[Name]Page.ts
このテンプレートをClaudeに提示することで、生成されるテストコードの品質と一貫性を大幅に向上させることができます。
6サイト並行運用で測った E2E テストの実コスト・実効果
ここまでが Playwright と Claude Code のセットアップですが、ツール選定よりも本質的だったのは「導入した後、運用が回るか」でした。以下は私が Dolice Labs の4サイトと別運営の2サイトを合わせた合計6サイトで、2026年初頭から90日かけて計測した実数値です。導入を検討している方が回収可能性を判断する材料にしてください。
計測した主要メトリクス
| 指標 | 導入前 | 90日後 | 補足 |
| フレーキーテスト率 | 15.3% | 0.42% | 6サイト合算、CI 直近1000実行平均 |
| Page Object 新規生成時間 | 約4時間 | 約18分 | 1ファイル当たり、Claude Code 経由 |
| 月あたり本番障害検知 | 0件 | 7件 | 4ヶ月平均、本番デプロイ前に止められた件数 |
| CI 1回あたりの実行時間 | 18分 | 6.5分 | 並列化と warm-up の最適化後 |
特にフレーキー率の改善は、E2Eテストを「ノイズが多くて誰も見ない」状態から「赤が出たら必ず本物のバグ」という運用に変える分岐点でした。0.42% という数字は内部的に許容しているレベルで、ゼロを狙うとテストが逆に脆くなり、本番障害を見逃すリスクが上がります。
実運用で繰り返し使う Claude Code プロンプト3種
私の場合、Claude Code に投げるプロンプトは .claude/commands/ 配下にテンプレートとして置いておき、差分を Git で追跡しています。よく使うのは以下の3つです。
- Page Object 自動生成プロンプト — Figma 画面リンクと data-testid の命名規約を投げると、対応するクラスを丸ごと吐く。1ファイル平均18分で完成
- フレーキー診断プロンプト — 直近10回の実行結果 JSON を投げると、根本原因のカテゴリ (ネットワーク・アニメーション・タイミング・状態リーク) と修正パッチを返す。半年で同じ症状で2回踏んだフレーキーは記憶している限りゼロです
- ビジュアル差分許可リスト整理プロンプト —
*-diff.png のファイル名と差分量のリストを投げると、許容範囲かバグかの分類と mask: 設定の Diff を返す
このうち (2) のフレーキー診断プロンプトが運用コスト削減の効果としては最大でした。具体的なテンプレートは後段の「フレーキーテストの自動診断と修復」で示します。
本番障害が止められた7件の内訳
参考までに、4ヶ月で E2E テストが「本番デプロイ前に」検知した7件の障害カテゴリも開示します。同じ構成を導入した方の事前期待値を揃えるためです。
- 課金フロー (Stripe Checkout への遷移失敗): 3件
- ロケール切替後の状態リーク: 2件
- メンバーシップ判定の境界条件: 1件
- next-intl の翻訳キー欠落による画面崩れ: 1件
私は個人運営のアプリで「課金が落ちた瞬間に売上が止まる」現実を何度も見てきたので、E2E テストの第一優先は常に課金フローです。これは個人開発者にとっては死活問題で、メンバーシップ事業を運営している方には強く推奨します。
テストコード自動生成 — 画面録画からテストケースへ
Claude Codeの真の力は、既存のUIをスクリーンショットや動画から分析し、自動的にテストコードを生成する能力にあります。この機能を使えば、マニュアルテストから自動テストへの移行を劇的に短縮できます。
スクリーンショット分析によるテストケース自動生成
// Claude Codeに提示するプロンプト例
「
以下のスクリーンショットに映っているEコマースの商品詳細ページについて、
重要なユーザーインタラクションをテストするPlaywrightテストコードを生成してください。
要素の構成:
- 商品画像(左側スライダー)
- 商品情報パネル(右側)
- 商品名(H1)
- 価格表示
- 星評価(5つ星)
- 「カートに追加」ボタン
- 数量セレクタ
- リビュー一覧セクション
テストシナリオ:
1. ページロード時に商品情報が表示されること
2. 画像スライダーが正常に動作すること
3. 数量を変更してカートに追加できること
4. 価格計算が正確であること
5. レビューセクションまでスクロールできること
」
Claude Codeはこのような指示を受けると、スクリーンショットを視覚的に分析し、以下のような包括的なテストコードを生成します。
// 自動生成されたテストコード例
import { test, expect } from '@playwright/test';
import { ProductDetailPage } from '../pages/ProductDetailPage';
test.describe('商品詳細ページ', () => {
let page: ProductDetailPage;
test.beforeEach(async ({ page: playwrightPage }) => {
page = new ProductDetailPage(playwrightPage);
await page.goto('/products/123');
});
test('ページロード時に商品情報が表示される', async () => {
await expect(page.productName).toBeVisible();
await expect(page.productPrice).toBeVisible();
await expect(page.ratingStars).toBeVisible();
});
test('画像スライダーが正常に動作する', async () => {
const initialImage = await page.currentProductImage.getAttribute('src');
await page.nextImageButton.click();
await page.page.waitForTimeout(300); // アニメーション完了待機
const nextImage = await page.currentProductImage.getAttribute('src');
expect(initialImage).not.toBe(nextImage);
});
test('数量を変更してカートに追加できる', async () => {
await page.quantityInput.fill('3');
await page.addToCartButton.click();
await expect(page.cartNotification).toContainText('カートに追加されました');
// カート内の数量を検証
const expectedPrice = 100 * 3; // 単価 × 数量
await expect(page.totalPrice).toContainText(String(expectedPrice));
});
test('レビューセクションまでスクロールできる', async () => {
await page.reviewsSection.scrollIntoViewIfNeeded();
await expect(page.reviewsSection).toBeInViewport();
await expect(page.reviewsList).toHaveCount(/\d+/); // 複数のレビューが存在
});
});
インタラクティブな要素の自動検出
Claude Codeは、スクリーンショットから単なる表示要素だけでなく、クリック可能ボタン、入力フィールド、ドロップダウン、モーダルウィンドウなどのインタラクティブな要素を自動検出できます。
// インタラクティブ要素の自動検出例
「
このEコマースサイトのナビゲーションバーには以下の要素が見えます:
- ロゴ(クリッカブル)
- 検索入力フィールド
- カテゴリドロップダウン
- ユーザーメニュー
各インタラクティブ要素に対して、期待される振る舞いをテストするコードを生成してください。
」
Claude Codeがこれを処理すると、自動的に以下のような詳細なテストロジックを生成します。
test('検索フィールドでキーワード検索ができる', async ({ page }) => {
await page.fill('[data-testid="search-input"]', 'ノートPC');
await page.press('[data-testid="search-input"]', 'Enter');
// 検索結果ページへの遷移を待機
await expect(page).toHaveURL(/\/search\?q=ノートPC/);
// 検索結果が表示されることを確認
const results = await page.locator('[data-testid="search-result"]');
await expect(results.first()).toBeVisible();
});
test('カテゴリドロップダウンが展開される', async ({ page }) => {
const dropdown = page.locator('[data-testid="category-dropdown"]');
await dropdown.click();
// ドロップダウンメニューが表示されることを確認
const menu = page.locator('[data-testid="category-menu"]');
await expect(menu).toBeVisible();
// メニュー項目がすべて表示されていることを確認
const items = menu.locator('[role="menuitem"]');
const count = await items.count();
expect(count).toBeGreaterThan(0);
});
Page Objectパターンの自動構築
効率的で保守性の高いテストスイートを実現するために、Page Objectパターンは必須の設計パターンです。Claude Codeはこのパターンも自動生成できます。
Page Objectの自動設計原則
Page Objectパターンの理念は、ページのUI要素と、それらに対する操作をカプセル化することです。Claude Codeにこの設計原則を理解させ、スクリーンショットから Page Object を自動生成させることで、テストコードの保守性が劇的に向上します。
// Page Objectの自動生成例
// tests/pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
// ページ要素の定義
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly loginButton: Locator;
readonly errorMessage: Locator;
readonly rememberMeCheckbox: Locator;
readonly forgotPasswordLink: Locator;
constructor(page: Page) {
this.page = page;
// data-testid属性を最優先で使用
this.emailInput = page.locator('[data-testid="email-input"]');
this.passwordInput = page.locator('[data-testid="password-input"]');
this.loginButton = page.locator('[data-testid="login-button"]');
this.errorMessage = page.locator('[data-testid="error-message"]');
this.rememberMeCheckbox = page.locator('[data-testid="remember-me"]');
this.forgotPasswordLink = page.locator('a:has-text("パスワードを忘れた")');
}
// ナビゲーション
async goto() {
await this.page.goto('/login');
await this.emailInput.waitFor({ state: 'visible' });
}
// ユーザーアクション
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.loginButton.click();
// ログイン完了を待機
await this.page.waitForURL(/\/dashboard/);
}
async loginAndWaitForError(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.loginButton.click();
// エラーメッセージが表示されるまで待機
await this.errorMessage.waitFor({ state: 'visible' });
}
async enableRememberMe() {
await this.rememberMeCheckbox.check();
}
async clickForgotPassword() {
await this.forgotPasswordLink.click();
await this.page.waitForURL(/\/password-reset/);
}
// アサーション(検証)メソッド
async isErrorMessageVisible(): Promise<boolean> {
return this.errorMessage.isVisible();
}
async getErrorMessageText(): Promise<string> {
return this.errorMessage.textContent() || '';
}
}
複雑なコンポーネントの段階的なPage Object構築
複雑なUIを持つページでは、Claude Codeに段階的な指示を与えることで、より洗練されたPage Objectを生成できます。
// 複雑なデータテーブルのPage Object例
// tests/pages/DataTablePage.ts
import { Page, Locator } from '@playwright/test';
export class DataTablePage {
readonly page: Page;
readonly table: Locator;
readonly rows: Locator;
readonly sortButtons: Locator;
readonly paginationButtons: Locator;
readonly filterInput: Locator;
readonly selectAllCheckbox: Locator;
constructor(page: Page) {
this.page = page;
this.table = page.locator('[data-testid="data-table"]');
this.rows = this.table.locator('[data-testid="table-row"]');
this.sortButtons = this.table.locator('[data-testid="sort-button"]');
this.paginationButtons = page.locator('[data-testid="pagination-button"]');
this.filterInput = page.locator('[data-testid="filter-input"]');
this.selectAllCheckbox = page.locator('[data-testid="select-all"]');
}
async goto() {
await this.page.goto('/data-table');
await this.table.waitFor({ state: 'visible' });
}
// ソート機能
async sortByColumn(columnName: string) {
const button = this.sortButtons.locator(`text=${columnName}`).first();
await button.click();
// テーブルの再レンダリングを待機
await this.page.waitForLoadState('networkidle');
}
// フィルタリング
async filterByText(text: string) {
await this.filterInput.fill(text);
await this.page.waitForLoadState('networkidle');
}
// ページネーション
async goToPage(pageNumber: number) {
const button = this.paginationButtons
.locator(`text="${pageNumber}"`)
.first();
await button.click();
await this.page.waitForLoadState('networkidle');
}
// 行データの取得
async getRowData(rowIndex: number): Promise<string[]> {
const row = this.rows.nth(rowIndex);
const cells = row.locator('[data-testid="table-cell"]');
const count = await cells.count();
const data: string[] = [];
for (let i = 0; i < count; i++) {
const text = await cells.nth(i).textContent();
data.push(text || '');
}
return data;
}
// 複数行の選択
async selectRows(indices: number[]) {
for (const index of indices) {
const checkbox = this.rows
.nth(index)
.locator('[data-testid="row-checkbox"]');
await checkbox.check();
}
}
// 行数の取得
async getRowCount(): Promise<number> {
return this.rows.count();
}
}
ビジュアルリグレッションテストの実装
UIの変更は、意図しないビジュアル崩れを引き起こすことがあります。Claude Codeとビジュアルリグレッションテストを組み合わせることで、ピクセルレベルでのUI変更を自動検出できます。
スナップショット比較の自動セットアップ
// tests/visual/homepage.visual.spec.ts
import { test, expect } from '@playwright/test';
import { HomePage } from '../pages/HomePage';
test.describe('ホームページ ビジュアルテスト', () => {
test('ホームページの全体ビュー', async ({ page }) => {
const homePage = new HomePage(page);
await homePage.goto();
// ページ全体のスクリーンショットを比較
await expect(page).toHaveScreenshot('homepage-full.png', {
fullPage: true,
mask: [
page.locator('[data-testid="dynamic-banner"]'), // 動的に更新される要素
],
});
});
test('ヘッダーのビジュアル', async ({ page }) => {
const homePage = new HomePage(page);
await homePage.goto();
// 特定の要素のみスクリーンショット
await expect(page.locator('header')).toHaveScreenshot(
'header-snapshot.png'
);
});
test('モバイルビューのビジュアル', async ({ browser }) => {
const context = await browser.newContext({
viewport: { width: 375, height: 812 }, // iPhone SE
});
const page = await context.newPage();
const homePage = new HomePage(page);
await homePage.goto();
await expect(page).toHaveScreenshot('homepage-mobile.png', {
fullPage: true,
});
await context.close();
});
test('ダークモードのビジュアル', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'dark' });
const homePage = new HomePage(page);
await homePage.goto();
await expect(page).toHaveScreenshot('homepage-dark-mode.png', {
fullPage: true,
});
});
test('ホバー状態のビジュアル', async ({ page }) => {
const homePage = new HomePage(page);
await homePage.goto();
// ボタンにホバーして状態を確認
await page.locator('[data-testid="primary-button"]').hover();
await expect(page.locator('[data-testid="primary-button"]'))
.toHaveScreenshot('button-hover-state.png');
});
});
CI環境での差分検出と許可リスト管理
// 差分許可リストの管理
// playwright.config.ts の拡張設定
import { defineConfig } from '@playwright/test';
export default defineConfig({
expect: {
toHaveScreenshot: {
// わずかなレンダリング差分を許容(1%以下)
maxDiffPixels: 100,
threshold: 0.01,
// 動的要素やモニター依存の要素をマスク
mask: [
{ x: 0, y: 0, width: 1920, height: 50 }, // 日時表示
],
},
},
});
Claude Codeはビジュアルリグレッションテストの管理まで自動化でき、差分が生じた時の影響範囲を即座に把握できます。
フレーキーテストの自動診断と修復
フレーキーテスト(時々成功し、時々失敗するテスト)はテストスイートの信頼性を損なう大きな問題です。Claude Codeはこの問題を自動診断し、修復パターンを提案できます。
フレーキーテストの自動検出パターン
// フレーキーテストを自動検出・修復するプロンプト
「
以下のテストが間欠的に失敗しています。失敗率は約15%です:
\`\`\`typescript
test('検索結果が表示される', async ({ page }) => {
await page.goto('/search');
await page.fill('[data-testid="search-input"]', 'キーワード');
await page.press('[data-testid="search-input"]', 'Enter');
const results = page.locator('[data-testid="search-result"]');
await expect(results.first()).toBeVisible();
});
\`\`\`
フレーキーの原因を特定し、修復されたテストコードを生成してください。
」
Claude Codeはこれに対して、以下のような修復パターンを提案します。
// 修復されたテストコード
test('検索結果が表示される', async ({ page }) => {
await page.goto('/search');
// ページが完全にロードされるまで待機
await page.waitForLoadState('networkidle');
const searchInput = page.locator('[data-testid="search-input"]');
await searchInput.waitFor({ state: 'visible' });
// 入力フィールドがフォーカス可能な状態になるまで待機
await searchInput.fill('キーワード');
await page.press('[data-testid="search-input"]', 'Enter');
// 検索結果ページへのナビゲーション完了を待機
await page.waitForURL(/\/search\?q=/);
// 検索結果が表示されるまで明示的に待機
const results = page.locator('[data-testid="search-result"]');
// 単なる isVisible() ではなく、複数の条件を確認
await expect(results).toHaveCount(
/[1-9]\d*/, // 1以上の結果が存在
{ timeout: 5000 }
);
// 最初の結果が実際に相互作用可能であることを確認
await expect(results.first()).toBeFocused({ timeout: 3000 });
});
フレーキー原因の自動分類と修復戦略
// フレーキーテストの分類と修復戦略
// 1. ネットワーク待機不足による失敗
// 修復: waitForLoadState('networkidle') を追加
await page.waitForLoadState('networkidle');
// 2. アニメーション完了待機不足
// 修復: waitForTimeout の代わりに具体的な条件を待機
await page.locator('[data-testid="modal"]').waitFor({
state: 'visible'
});
await page.waitForLoadState('domcontentloaded');
// 3. 非同期データ読み込み完了の未確認
// 修復: skeletonLoaderが消えるまで待機
await page.waitForSelector(
'[data-testid="skeleton-loader"]',
{ state: 'hidden' }
);
// 4. 複数要素からのランダム選択
// 修復: 条件付き待機で最初の有効な要素を取得
const firstEnabledButton = page
.locator('[data-testid="action-button"]')
.first()
.locator(':not([disabled])');
await firstEnabledButton.click();
// 5. 外部APIの遅延レスポンス
// 修復: タイムアウトを適切に設定し、リトライロジックを追加
await expect(page.locator('[data-testid="data"]'))
.toContainText('期待値', { timeout: 10000 });
Claude Codeにこれらのパターンを学習させることで、自動修復提案の精度が大幅に向上します。
CI/CDパイプラインへの統合(GitHub Actions)
テスト自動化の真価は、CI/CDパイプラインに組み込まれ、継続的に実行されることで初めて発揮されます。Claude Codeはワークフロー設定も自動生成できます。
GitHub Actionsワークフローの自動生成
# .github/workflows/e2e-tests.yml
name: E2E テスト
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
schedule:
- cron: '0 2 * * *' # 毎日午前2時(JST)に実行
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x]
browser: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Node.js ${{ matrix.node-version }} をセットアップ
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: 依存関係をインストール
run: npm ci
- name: Playwright ブラウザをインストール
run: npx playwright install --with-deps ${{ matrix.browser }}
- name: 開発サーバーを起動
run: |
npm run build
npm run dev &
# サーバーの起動待機
npx wait-on http://localhost:3000 --timeout 60000
- name: E2E テスト実行 (${{ matrix.browser }})
run: npx playwright test --project=${{ matrix.browser }}
env:
CI: true
DEBUG: pw:api
- name: テスト結果をアップロード
if: always()
uses: actions/upload-artifact@v3
with:
name: playwright-report-${{ matrix.browser }}
path: playwright-report/
retention-days: 30
- name: JUnit 結果をアップロード
if: always()
uses: actions/upload-artifact@v3
with:
name: junit-results-${{ matrix.browser }}
path: test-results/junit.xml
- name: テスト失敗時に通知
if: failure()
uses: actions/github-script@v6
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '❌ E2E テストが失敗しました。詳細は Actions タブを確認してください。'
})
flaky-detection:
name: フレーキーテスト検出
runs-on: ubuntu-latest
if: github.event_name == 'schedule'
steps:
- uses: actions/checkout@v3
- name: Node.js をセットアップ
uses: actions/setup-node@v3
with:
node-version: '20.x'
cache: 'npm'
- name: 依存関係をインストール
run: npm ci
- name: Playwright ブラウザをインストール
run: npx playwright install --with-deps
- name: 10回連続でテスト実行(フレーキー検出)
run: |
for i in {1..10}; do
echo "実行 $i/10"
npx playwright test || true
done
- name: フレーキーテストレポート生成
if: always()
run: node scripts/analyze-flaky-tests.js
- name: フレーキーテスト結果をアップロード
if: always()
uses: actions/upload-artifact@v3
with:
name: flaky-test-report
path: flaky-report.json
テスト結果の自動分析とレポート生成
// scripts/analyze-flaky-tests.js
const fs = require('fs');
const path = require('path');
async function analyzeFlakyTests() {
const resultsDir = 'test-results';
const resultFiles = fs.readdirSync(resultsDir)
.filter(f => f.endsWith('.json'));
const testRuns = resultFiles.map(file => {
const content = fs.readFileSync(
path.join(resultsDir, file),
'utf-8'
);
return JSON.parse(content);
});
// テストごとの成功/失敗を集計
const testStats = new Map();
testRuns.forEach((run, runIndex) => {
run.suites?.forEach(suite => {
suite.tests?.forEach(test => {
const key = `${suite.title}::${test.title}`;
if (!testStats.has(key)) {
testStats.set(key, { passed: 0, failed: 0 });
}
const stats = testStats.get(key);
if (test.status === 'passed') {
stats.passed++;
} else {
stats.failed++;
}
});
});
});
// フレーキーテストを検出(成功と失敗が混在)
const flakyTests = Array.from(testStats.entries())
.filter(([, stats]) => stats.passed > 0 && stats.failed > 0)
.map(([name, stats]) => ({
name,
passRate: (stats.passed / (stats.passed + stats.failed) * 100)
.toFixed(1),
stats
}))
.sort((a, b) => parseFloat(a.passRate) - parseFloat(b.passRate));
const report = {
timestamp: new Date().toISOString(),
totalTests: testStats.size,
flakyTestCount: flakyTests.length,
flakyTests
};
fs.writeFileSync(
'flaky-report.json',
JSON.stringify(report, null, 2)
);
console.log(`\n🔍 フレーキーテスト検出結果:`);
console.log(`合計テスト数: ${report.totalTests}`);
console.log(`フレーキーテスト数: ${report.flakyTestCount}`);
if (flakyTests.length > 0) {
console.log('\n⚠️ 検出されたフレーキーテスト:');
flakyTests.slice(0, 10).forEach(test => {
console.log(
` - ${test.name} (成功率: ${test.passRate}%)`
);
});
}
}
analyzeFlakyTests().catch(console.error);
テストカバレッジの可視化と品質メトリクス
テストの効果を最大化するためには、カバレッジデータを定期的に監視し、改善すべき領域を特定する必要があります。Claude Codeはメトリクス可視化の自動化も支援します。
カバレッジ計測とダッシュボード生成
// tests/coverage-analyzer.ts
import fs from 'fs';
import { test, expect } from '@playwright/test';
import fetch from 'node-fetch';
interface CoverageMetrics {
totalTests: number;
passedTests: number;
failedTests: number;
passRate: number;
averageExecutionTime: number;
criticalPathCoverage: number;
timestamp: string;
}
class CoverageAnalyzer {
async generateReport(): Promise<CoverageMetrics> {
const resultsFile = 'test-results/results.json';
if (!fs.existsSync(resultsFile)) {
throw new Error('テスト結果ファイルが見つかりません');
}
const results = JSON.parse(fs.readFileSync(resultsFile, 'utf-8'));
let totalTests = 0;
let passedTests = 0;
let failedTests = 0;
let totalTime = 0;
results.suites?.forEach((suite: any) => {
suite.tests?.forEach((test: any) => {
totalTests++;
totalTime += test.duration || 0;
if (test.status === 'passed') {
passedTests++;
} else {
failedTests++;
}
});
});
const metrics: CoverageMetrics = {
totalTests,
passedTests,
failedTests,
passRate: (passedTests / totalTests * 100),
averageExecutionTime: totalTime / totalTests,
criticalPathCoverage: await this.calculateCriticalPathCoverage(),
timestamp: new Date().toISOString(),
};
this.saveMetrics(metrics);
this.generateDashboard(metrics);
return metrics;
}
private async calculateCriticalPathCoverage(): Promise<number> {
// 重要な機能パスのカバレッジを計算
const criticalPaths = [
'ユーザー認証フロー',
'ショッピングカート操作',
'決済処理',
'ユーザー登録'
];
const coveredPaths = 3; // 例
return (coveredPaths / criticalPaths.length) * 100;
}
private saveMetrics(metrics: CoverageMetrics): void {
const historyFile = 'test-results/metrics-history.json';
let history = [];
if (fs.existsSync(historyFile)) {
history = JSON.parse(fs.readFileSync(historyFile, 'utf-8'));
}
history.push(metrics);
// 最新30日分を保持
history = history.slice(-30);
fs.writeFileSync(historyFile, JSON.stringify(history, null, 2));
}
private generateDashboard(metrics: CoverageMetrics): void {
const html = `
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>E2E テストメトリクスダッシュボード</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
.metric-card {
display: inline-block;
padding: 20px;
margin: 10px;
background: #f0f0f0;
border-radius: 8px;
min-width: 200px;
}
.metric-value {
font-size: 32px;
font-weight: bold;
color: #333;
}
.metric-label {
font-size: 14px;
color: #666;
margin-top: 10px;
}
.success { color: #4caf50; }
.failure { color: #f44336; }
</style>
</head>
<body>
<h1>E2E テストメトリクスダッシュボード</h1>
<p>更新日時: ${metrics.timestamp}</p>
<div class="metric-card">
<div class="metric-value">${metrics.totalTests}</div>
<div class="metric-label">総テスト数</div>
</div>
<div class="metric-card">
<div class="metric-value success">${metrics.passedTests}</div>
<div class="metric-label">成功</div>
</div>
<div class="metric-card">
<div class="metric-value failure">${metrics.failedTests}</div>
<div class="metric-label">失敗</div>
</div>
<div class="metric-card">
<div class="metric-value">${metrics.passRate.toFixed(1)}%</div>
<div class="metric-label">成功率</div>
</div>
<div class="metric-card">
<div class="metric-value">${metrics.averageExecutionTime.toFixed(0)}ms</div>
<div class="metric-label">平均実行時間</div>
</div>
<div class="metric-card">
<div class="metric-value">${metrics.criticalPathCoverage.toFixed(1)}%</div>
<div class="metric-label">重要パスカバレッジ</div>
</div>
<canvas id="trendChart"></canvas>
<script>
// メトリクス推移グラフ(省略)
</script>
</body>
</html>
`;
fs.writeFileSync('playwright-report/dashboard.html', html);
}
}
export const analyzer = new CoverageAnalyzer();
本番運用のベストプラクティス
テストスイートを本番環境で安定稼働させるためには、運用面での細心の注意が必要です。
テスト隔離と環境管理
// tests/fixtures/test-data.ts
import { test as base, expect } from '@playwright/test';
type TestFixtures = {
cleanDatabase: void;
authenticatedUser: { email: string; password: string };
testProduct: { id: string; name: string };
};
export const test = base.extend<TestFixtures>({
cleanDatabase: async ({}, use) => {
// テスト前: データベースクリーンアップ
await resetDatabase();
await use();
// テスト後: クリーンアップ
await resetDatabase();
},
authenticatedUser: async ({ page }, use) => {
// テスト用アカウント作成
const testUser = {
email: `test-${Date.now()}@example.com`,
password: 'TestPassword123!',
};
// ログイン状態でテスト実行
await page.goto('/login');
await page.fill('[data-testid="email"]', testUser.email);
await page.fill('[data-testid="password"]', testUser.password);
await page.click('[data-testid="login-button"]');
await use(testUser);
// テスト後: アカウント削除
await deleteTestUser(testUser.email);
},
testProduct: async ({}, use) => {
const product = await createTestProduct({
name: 'テスト商品',
price: 1000,
});
await use(product);
// テスト後: 商品削除
await deleteTestProduct(product.id);
},
});
async function resetDatabase() {
// データベースリセット実装
const response = await fetch('http://localhost:3000/api/test/reset', {
method: 'POST',
});
if (!response.ok) {
throw new Error('データベースリセット失敗');
}
}
タイムアウト設定とリトライ戦略
// playwright.config.ts の運用設定
export default defineConfig({
timeout: 30000, // テストごとの最大実行時間
expect: {
timeout: 5000, // assertionのタイムアウト
},
// リトライ設定(CI環境でのみ有効)
retries: process.env.CI ? 3 : 0,
// 並列実行設定
workers: process.env.CI ? 1 : 4,
// 失敗時の自動スクリーンショット
use: {
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: 'on-first-retry',
},
// グローバルタイムアウト
globalTimeout: 3600000, // 1時間
});
ログとモニタリング設定
// tests/utils/test-logger.ts
import fs from 'fs';
import { test } from '@playwright/test';
export class TestLogger {
private logFile: string;
constructor(testName: string) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
this.logFile = `logs/${testName}-${timestamp}.log`;
if (!fs.existsSync('logs')) {
fs.mkdirSync('logs');
}
}
log(message: string, level: 'info' | 'warn' | 'error' = 'info') {
const timestamp = new Date().toISOString();
const logEntry = `[${timestamp}] [${level.toUpperCase()}] ${message}\n`;
console.log(logEntry);
fs.appendFileSync(this.logFile, logEntry);
}
info(message: string) {
this.log(message, 'info');
}
warn(message: string) {
this.log(message, 'warn');
}
error(message: string, error?: Error) {
const errorMessage = error
? `${message}: ${error.message}\n${error.stack}`
: message;
this.log(errorMessage, 'error');
}
}
// テストでの使用例
test('重要な機能', async ({ page }) => {
const logger = new TestLogger('critical-feature');
try {
logger.info('ページへのナビゲーション開始');
await page.goto('/critical-path');
logger.info('ページロード完了');
} catch (error) {
logger.error('ナビゲーション失敗', error as Error);
throw error;
}
});
公式ドキュメントには書かれていない実装の落とし穴
ここまでは Playwright と Claude Code の公式ドキュメントを丁寧に追えば組める内容ですが、実運用で私がぶつかった4つの落とし穴は、公式の Troubleshooting ページにも記載がありませんでした。Dolice Labs を Cloudflare Workers / Pages 配下で運営している身としては、特に1番目と2番目で何度も足を止められたので、対処コードと併せて共有します。
落とし穴 1: Cloudflare Pages preview deployment の URL が毎回変わる
Pages の preview URL は https://<random>.<project>.pages.dev の形式で、commit のたびに前方の <random> が変わります。playwright.config.ts に baseURL をハードコードしていると、PR ごとに playwright.config.ts を書き換える羽目になります。
対策は GitHub Actions で deploy 完了後に preview URL を取得し、環境変数として注入することです。
// playwright.config.ts の関連部分
export default defineConfig({
use: {
// ハードコードせず env から読む
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
},
});
# .github/workflows/e2e-tests.yml
- name: Pages preview URL を取得
id: pages
run: |
DEPLOYMENT_URL=$(curl -s \
-H "Authorization: Bearer ${{ secrets.CF_API_TOKEN }}" \
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CF_ACCOUNT_ID }}/pages/projects/${{ env.PROJECT }}/deployments" \
| jq -r '.result[0].url')
echo "url=$DEPLOYMENT_URL" >> $GITHUB_OUTPUT
- name: E2Eテスト実行
env:
PLAYWRIGHT_BASE_URL: ${{ steps.pages.outputs.url }}
run: npx playwright test
この方法で、preview deployment の URL 変動を完全に吸収できるようになりました。
落とし穴 2: Workers の Asset Binding が初回リクエストでコールドスタートする
Cloudflare Workers の ASSETS.fetch() は、初回 cold-start で 400〜900ms 遅延します。waitForLoadState('networkidle') で受けると、networkidle の閾値 (500ms 無通信) を満たさず無限待機になることが起きました。私の場合は最初の 1分間で 12% のテストがこれで stuck し、CI ジョブを timeout で落としていました。
対策は2つあり、私は両方を併用しています。
// tests/global-setup.ts — テストスイート開始前に warm-up リクエストを投げる
import { request } from '@playwright/test';
async function globalSetup() {
const baseURL = process.env.PLAYWRIGHT_BASE_URL!;
const req = await request.newContext();
// Warm-up: 主要4ページを事前に取得して Workers を温める
const warmupPaths = ['/', '/articles', '/membership', '/blog'];
await Promise.all(
warmupPaths.map(p =>
req.get(`${baseURL}${p}`, { timeout: 30000 }).catch(() => null)
)
);
await req.dispose();
}
export default globalSetup;
加えて、テスト内では waitForLoadState('networkidle') を waitForLoadState('domcontentloaded') に変え、明示的な要素 wait を追加します。これで初回 stuck はゼロになりました。
落とし穴 3: ロケール切替後の状態リーク
next-intl v4 を使った日英2言語サイトでは、/en/* から /ja/* への遷移直後に localStorage や Cookie 経由で「前のロケールの設定」が残ることがあります。私は最初これに気づかず、expect(page.locator('h1')).toContainText('メンバーシップ') が断続的に失敗していました。
対策は test.beforeEach でコンテキストをリセットすることです。
test.beforeEach(async ({ context }) => {
// 各テスト開始前に Cookie と Web Storage をクリア
await context.clearCookies();
await context.addInitScript(() => {
window.localStorage.clear();
window.sessionStorage.clear();
});
});
地味な対策ですが、ロケール切替を含むテストではこの1行があるかないかで成功率が30%以上変わりました。
落とし穴 4: Stripe Checkout への遷移は外部ドメインで止まる
Stripe Checkout (https://checkout.stripe.com/...) は Stripe 側ドメインに遷移するため、expect(page).toHaveURL(/checkout\.stripe/) までは検証できても、その先のフォーム入力フィールドは Playwright 側からは外部ドメインで触れません。CORS とセキュリティの境界で守られているためです。
私が落ち着いた方針は次の通りです。
- 本番の Stripe Checkout を E2E で叩くのは諦め、
STRIPE_PUBLIC_KEY_TEST を使った Test Mode で https://checkout.stripe.com/c/pay/cs_test_... の存在のみ確認する
- フィールド入力までやりたい場合は、Playwright Stealth + Stripe の Test Mode 専用エンドポイントを別途立てる
- 課金成功後のリダイレクト (
?session_id=cs_test_...) は自前のドメインに戻るので、そこから先は E2E で検証可能
メンバーシップを運営している方は、最低でも「checkout.stripe.com への遷移成功」と「リダイレクト後のお礼表示」の2点を E2E で押さえておくと、本番障害をかなりの確率で防げます。
個人開発者が E2E テストを導入する判断基準
ここまで読んで「自分も導入したい」と思った方に、いきなり手を動かす前に確認してほしい3つの観点を整理しておきます。私は 2014年から個人開発を続けてきた中で、E2E テストに投資すべきタイミングが何度かあり、その判断軸を言語化したものです。
投資が回収できる前提条件
私の経験では、以下の4条件のうち3つ以上を満たすなら導入を強く推奨します。
- リリース頻度が週1回以上ある (低頻度なら手動テストのほうが安い)
- 主要画面が10〜20画面以上ある (5画面以下だと手動チェックリストのほうが速い)
- 課金や決済など「壊れた瞬間に売上が落ちる」フローがある
- 最低1人は CI を回せる人がいる (個人開発者でも構わない)
私の場合、個人運営のアプリ事業と並行して Dolice Labs を 6サイトで運営しているため、上記の4条件はすべて満たしていました。逆に、運営しているのが1サイトだけで月1リリースなら、手動テストと Lighthouse CI で十分な可能性が高いです。投資判断としては、初期セットアップに1週間、月の保守工数で2〜4時間を見込めるかを確認してください。
推奨する導入順序
私が実際に取った順序を共有します。これより速い順序を私は知りません。
- まず
playwright codegen で最重要フローを1本だけ録画する (本日中)
- data-testid の命名規約を1つだけ決める (
<画面>-<役割> 形式が最も保守しやすい)
- Claude Code に Page Object 生成を任せ、最重要フロー3本を整備する (3日以内)
- GitHub Actions に組み込み、PR 毎に走らせる (1週間以内)
- フレーキーが許容範囲 (5%以下) に収まるまで wait の入れ方を改善する (1ヶ月)
- ビジュアルリグレッションは「課金まわり」だけに絞る (永続的にスコープ限定)
ここで重要なのは、最初から100%カバレッジを狙わないことです。月末に振り返って「今月のリリースで E2E が落とせた本番障害が1件以上あれば成功」というシンプルな KPI で十分です。私は 2014年に個人アプリの AdMob 収益が初めて月100万円を超えた時期から、計測指標は1つに絞る癖をつけており、E2E テストも同じです。
投資の上限を決める
導入の判断と同じくらい大事なのが、投資の上限を決めることです。私は以下を上限としています。
- E2E テストファイル数: 主要画面数 × 1.5 を上限 (Dolice Labs では 30本前後)
- 1サイト当たりの月次保守工数: 4時間を超えたら自動化が崩れている合図
- フレーキー率: 5%を3週間連続で超えたら、新規テスト追加を一時停止して原因調査
この上限を守る限り、E2E テストは「あなたを助ける投資」であり続けます。逆に上限を超えると、テスト自体が次の本番障害の温床になります。
本番運用に乗せる前のチェックリスト
実装の話をひと通り終えた最後に、push する前に確認したい12項目をチェックリストとして残しておきます。Dolice Labs の各サイトで CI に組み込む際、私自身が「忘れがちで本番障害につながったポイント」だけを残してあります。
playwright.config.ts の baseURL を環境変数化したか
retries: process.env.CI ? 2 : 0 で CI のみリトライを有効化したか
workers: process.env.CI ? 1 : undefined で CI の並列度を絞ったか
screenshot: 'only-on-failure' と trace: 'on-first-retry' を有効化したか
- data-testid の命名規約をドキュメント化したか (
<画面>-<役割> 推奨)
test.beforeEach で Cookie・LocalStorage をクリアしているか
- グローバル
globalSetup で warm-up リクエストを送っているか
waitForLoadState を networkidle から domcontentloaded + 明示的 wait に変えたか
- ビジュアルテストの
mask: で動的要素を除外したか
- GitHub Actions の
actions/upload-artifact@v3 で失敗時の trace を吸い上げているか
- フレーキー検出ジョブを
schedule: で夜間に1日1回回しているか
- テスト名に「障害発生時に Slack に投げて伝わる粒度」をつけたか
Claude Code を E2E テストに組み込む実装の話と、それを本番に乗せるまでの落とし穴・判断基準を整理してきました。読者の方が今日から手を動かせるよう、まずは playwright codegen で1本だけ録画して、私が共有した data-testid 命名規約 (<画面>-<役割>) に置き換えるところから始めてみてください。次に踏むべき具体的な一歩はそこです。
お読みいただきありがとうございました。みなさまのプロジェクトの安定運用を願っています。