個人で複数のアプリを長く運営してきた中で、何度も同じ後悔をしてきました。「リリース前に E2E テストを書いておけば、この本番障害は防げた」というやつです。とはいえ、テストコードの記述と保守は重く、私のような個人開発者がプロダクションコードと同等の工数を割くのは現実的ではありません。
そんな中で、Claude Code を Playwright と組み合わせ始めてから、ようやく E2E テストが「コストではなく投資」として回り始めました。本稿は、自分で運営している6つのサイト (技術ブログ4つと、別ジャンルのメディア2つ) にこの構成を段階的に展開してきた中で計測した数値と、公式ドキュメントには書かれていなかった落とし穴を整理したものです。
最初に結論を共有しておきます。導入から90日で計測できた変化は次の3つです。
フレーキーテスト率: 15.3% → 0.42% (収束値)
Page Object クラスの新規生成時間: 約4時間 → 約18分 (1ファイル当たり、約13倍)
月あたりの「本番デプロイ前に止められた」障害: 0件 → 7件 (4ヶ月平均)
この数字を出すまでの過程で、私が踏んだ落とし穴と、個人開発者として E2E テストに投資すべきかを判断する基準を、本稿の後半で具体的にお伝えします。
最初の30分 — Playwright を入れて Claude Code に繋ぐまで
Playwright を選んだ理由は、Chrome・Firefox・Safari の3エンジンを1つの設定ファイルで回せる点に尽きます。私の場合、Safari 固有のレイアウト崩れを本番で2度踏んでいたため、WebKit をローカルで再現できることが決め手でした。まずは、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 のセットアップですが、ツール選定よりも本質的だったのは「導入した後、運用が回るか」でした。以下は私が運営している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に提示するプロンプト例
「
以下のスクリーンショットに映っている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にこれらのパターンを学習させることで、自動修復提案の精度が大幅に向上します。
単発のレポートでは見えないフレーキーを拾う
ここまでは「Playwright が flaky と記録したテスト」を直す話でした。ただ、運用を続けるうちに、もっと厄介な種類の不安定さがあることに気づきました。
Playwright が flaky と印をつけるのは、同じ実行の中でリトライして緑になった ケースだけです。1回目で落ちて2回目で通れば flaky ですが、月曜の実行で落ちて火曜の実行で通ったテストは、どちらのレポートでも「ただの赤」と「ただの緑」にしか見えません。単発のレポートを眺めている限り、この実行間の反転は永久に検出できないのです。
そこで、夜間ジョブで貯めたレポートをまとめて突き合わせる小さなスクリプトを挟んでいます。
#!/usr/bin/env python3
"""複数回の Playwright JSON レポートを突き合わせ、実行間で結果が反転するテストを検出する。"""
import json, sys, glob
from collections import defaultdict
def walk (node, out):
for spec in node.get( "specs" , []):
key = f ' { spec.get( "file" , "?" ) } :: { spec.get( "title" , "?" ) } '
for t in spec.get( "tests" , []):
out[key].append(t.get( "status" , "unknown" ))
for child in node.get( "suites" , []):
walk(child, out)
def load (path):
with open (path, encoding = "utf-8" ) as fh:
data = json.load(fh)
out = defaultdict( list )
for suite in data.get( "suites" , []):
walk(suite, out)
return {k: v[ 0 ] for k, v in out.items() if v}
def main (pattern, threshold):
paths = sorted (glob.glob(pattern))
if not paths:
print ( f "レポートが見つかりません: { pattern } " )
return 2
history = defaultdict( list )
for p in paths:
for key, status in load(p).items():
history[key].append(status)
within, across, stable = [], [], 0
for key, statuses in history.items():
seen = set (statuses)
if "flaky" in seen:
within.append((key, statuses))
elif { "expected" , "unexpected" } <= seen:
across.append((key, statuses))
else :
stable += 1
total = len (history)
unstable = len (within) + len (across)
rate = unstable / total * 100 if total else 0.0
print ( f "レポート { len (paths) } 本 / テスト { total } 件" )
print ( f " 実行内フレーキー (Playwright が flaky と記録): { len (within) } 件" )
print ( f " 実行間フレーキー (単発では常に緑に見える) : { len (across) } 件" )
print ( f " 安定 : { stable } 件" )
print ( f " 不安定率: { rate :.2f } % (閾値 { threshold :.2f } %)" )
if across:
print ( " \n -- 単発レポートでは検出できなかったテスト --" )
for key, statuses in across:
print ( f " { key }\n { ' ' .join(statuses) } " )
return 1 if rate > threshold else 0
if __name__ == "__main__" :
pattern = sys.argv[ 1 ] if len (sys.argv) > 1 else "reports/*.json"
threshold = float (sys.argv[ 2 ]) if len (sys.argv) > 2 else 5.0
sys.exit(main(pattern, threshold))
walk() を再帰にしてあるのは、Playwright の JSON レポートが suites を入れ子で返すためです。トップレベルだけを見ると、ディレクトリを切ってあるプロジェクトではテストを取りこぼします。
動きを確かめるため、12件のテストのうち3件だけを意図的に不安定にしたサンプルレポートを5本用意して食わせました。手元で実際に出た出力が次のものです。
$ python3 flaky_rate.py "reports/run-1.json" 5.0
レポート 1 本 / テスト 12 件
実行内フレーキー (Playwright が flaky と記録): 1 件
実行間フレーキー (単発では常に緑に見える) : 0 件
安定 : 11 件
不安定率: 8.33% (閾値 5.00%)
$ python3 flaky_rate.py "reports/*.json" 5.0
レポート 5 本 / テスト 12 件
実行内フレーキー (Playwright が flaky と記録): 1 件
実行間フレーキー (単発では常に緑に見える) : 2 件
安定 : 9 件
不安定率: 25.00% (閾値 5.00%)
-- 単発レポートでは検出できなかったテスト --
tests/e2e/locale.spec.ts::日英切替でパスが保持される
expected unexpected expected unexpected expected
tests/e2e/article.spec.ts::タグページが200を返す
expected expected expected expected unexpected
同じテストスイートに対して、レポート1本だと 8.33%、5本を突き合わせると 25.00% になります。差の 2件は、どの単発レポートを開いても flaky とは書かれていません。ロケール切替のテストは5回中2回落ちており、実運用なら真っ先に手を入れるべき対象ですが、単発の緑を見て安心していた期間が私にもありました。
出力の並び (expected unexpected expected unexpected expected) をそのまま残しているのは、反転のパターンに意味があるからです。交互に反転していれば実行順序や状態リークを疑い、最後の1回だけ落ちていれば直近の変更かデプロイ環境を疑う、という切り分けに使えます。
終了コードは、閾値を超えたら 1、収まっていれば 0、レポートが1本も見つからなければ 2 を返すようにしてあります。ここを3値にしておかないと、レポートの収集自体が失敗している夜に「不安定率 0%」と表示されて緑になってしまいます。私はこれで一度、2週間ほど計測が止まっていたことに気づけませんでした。
GitHub Actions では、夜間ジョブの最後にこう繋いでいます。
- name : Aggregate flaky rate across recent runs
run : |
python3 scripts/flaky_rate.py "artifacts/reports/*.json" 5.0
後述の「投資の上限を決める」で挙げるフレーキー率5%という基準は、この終了コードで機械的に守らせています。人の目で守れる約束ではなかった、というのが正直なところです。
CI/CDパイプラインへの統合(GitHub Actions)
手元で緑になるテストは、正直なところ何の保険にもなりません。PR のたびに勝手に走り、赤ければマージを止める ところまで組んで、ようやく E2E テストは本番障害の手前に立ちます。ここが一番後回しにされやすい工程だと感じています。
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 ページにも記載がありませんでした。運営中のサイトを 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つの観点を整理しておきます。個人開発を続ける中で E2E テストに投資すべきタイミングは何度かあり、そのたびに迷った末に言語化した判断軸です。
投資が回収できる前提条件
私の経験では、以下の4条件のうち3つ以上を満たすなら導入を強く推奨します。
リリース頻度が週1回以上ある (低頻度なら手動テストのほうが安い)
主要画面が10〜20画面以上ある (5画面以下だと手動チェックリストのほうが速い)
課金や決済など「壊れた瞬間に売上が落ちる」フローがある
最低1人は CI を回せる人がいる (個人開発者でも構わない)
私の場合、個人運営のアプリ事業と並行して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 で十分です。指標を増やすほど、どれも見なくなります。E2E テストで見る数字も1つに絞るのが、結局いちばん長続きしました。
投資の上限を決める
導入の判断と同じくらい大事なのが、投資の上限を決めることです。私は以下を上限としています。
E2E テストファイル数: 主要画面数 × 1.5 を上限 (私の場合は1サイト 30本前後)
1サイト当たりの月次保守工数: 4時間を超えたら自動化が崩れている合図
フレーキー率: 5%を3週間連続で超えたら、新規テスト追加を一時停止して原因調査
この上限を守る限り、E2E テストは「あなたを助ける投資」であり続けます。逆に上限を超えると、テスト自体が次の本番障害の温床になります。
本番運用に乗せる前のチェックリスト
実装の話をひと通り終えた最後に、push する前に確認したい12項目をチェックリストとして残しておきます。各サイトで 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 命名規約 (<画面>-<役割>) に置き換えるところから始めてみてください。次に踏むべき具体的な一歩はそこです。
お読みいただきありがとうございました。みなさまのプロジェクトの安定運用を願っています。