Playwright E2Eがすぐ壊れる僕が、フレーキーを潰すまで
Claude CodeでPlaywright E2Eを書く実践記。ロケータの選び方、auto-wait、storageState、CIのheadless実行、Trace Viewerでフレーキーテストを潰す手順を失敗談つきで。
金曜の夕方、Claude Codeに「購入フローのE2E書いて」と頼んだら、10分後にはちゃんと緑になるテストが出てきました。週末、満足してデプロイ。
月曜の朝、CIが赤い。テストは何も変えてないのに。再実行したら通る。もう一回回したら、また落ちる。
これがフレーキーテスト(flaky test)、つまり「同じコードなのに通ったり落ちたりするテスト」です。僕はこれで土日明けの午前をまるごと溶かしました。原因は、Claude Codeが書いた page.waitForTimeout(2000) と、.card:nth-child(3) みたいなCSSセレクタ。賢いAIが書いた、見た目はきれいなコードが、いちばん壊れやすかった。
この記事は、その失敗を出発点に、僕がPlaywrightのE2E(ブラウザを最後まで操作するテスト)を「落ち着いて緑になる状態」に持っていくまでの手順です。単体テストの話はしません。関数1個のテストやモックの細かい話はClaude CodeでVitest上級テストを実装する実践ガイドに分けてあります。ここはブラウザだけ。
この記事の要点
- フレーキーの主犯は
waitForTimeoutと CSSセレクタ。この2つを消すだけで体感8割減る。 - ロケータは
getByRole/getByLabelを最優先。Playwrightの auto-wait(自動待機)が効くので、自分で待つコードは要らない。 - ログインは毎回UIでやらず
storageStateに保存して使い回す。速くて壊れにくい。 - CIは headless で回し、retryは2回まで、Trace Viewer で「落ちた瞬間の画面」を録画する。
- Claude Codeには「テスト書いて」ではなく ユーザーシナリオ+禁止事項 を渡す。これで出てくるコードの質が変わる。
まずフレーキーの正体を知る
フレーキーテストは運が悪いから起きるわけではありません。原因はだいたい決まっています。僕が踏んだ順に並べます。
| よくある原因 | 何が起きるか | 直し方 |
|---|---|---|
waitForTimeout(2000) | 速い時は無駄、遅い時は足りない | Web-first assertion で待つ |
CSSセレクタ(.btn, nth-child) | デザイン変更で即崩壊 | role / label に置き換え |
| ログインをUIで毎回 | 画面差し替えで全部落ちる | storageState に分離 |
| 時刻・乱数に依存 | 日付やランダムIDでたまにズレる | データを固定する |
| アニメーション中にクリック | 要素が動いてる最中に触る | 要素の状態で待つ |
一番タチが悪いのが先頭の固定待ち(waitForTimeout)です。「2秒待てば描画が終わるだろう」という願望なんですが、CIのマシンが混んでいる日は3秒かかる。すると落ちる。逆に速い日は2秒丸ごと無駄になり、テスト全体が遅くなる。待ち時間を秒数で決めた瞬間、テストはフレーキーの種を抱えます。
ここを直すカギが、次のauto-waitです。
auto-waitを信じて、自分で待つのをやめる
Playwrightのロケータ(page.getByRole(...) などで作る要素の参照)は、賢い仕掛けを持っています。クリックや入力の直前に、その要素が表示されて操作できる状態になるまで自動で待つ。これがauto-waitです。
だから本来、こう書く必要はありません。
// アンチパターン: 自分で時間を決めて待つ
await page.waitForTimeout(2000);
await page.click('.submit-button');
こう書けば十分です。
// 推奨: ボタンが押せる状態になるまでPlaywrightが待つ
await page.getByRole('button', { name: /送信|submit/i }).click();
検証(assertion)も同じ思想です。expect(locator).toBeVisible() のようなWeb-first assertionは、条件が満たされるまで一定時間リトライしてくれます。「表示されたか今この瞬間に確認する」のではなく「表示されるまで待って確認する」。だから固定待ちが要らない。
// URLが切り替わるまで、表示されるまで、勝手に待ってくれる
await expect(page).toHaveURL(/\/products\/?$/);
await expect(page.getByRole('heading', { name: /教材/ })).toBeVisible();
詳しい挙動はPlaywright公式のAuto-waitingに一覧があります。僕は「waitForTimeout を書きたくなったら負け」と自分ルールにしてから、フレーキーが激減しました。
ロケータはユーザーが見るもので選ぶ
auto-waitと並ぶもう一つの主犯がセレクタです。Claude Codeに任せると、たまに .card:nth-child(3) > div.btn-primary みたいなものを出してきます。動きはします。でもデザイナーがカードの順番を入れ替えた瞬間に死ぬ。
ロケータは「ユーザーや支援技術が要素をどう認識するか」で選ぶと、見た目の変更に強くなります。優先順位はこうです。
| 優先度 | ロケータ | 例 | なぜ強いか |
|---|---|---|---|
| 高 | role + name | getByRole('button', { name: /購入/ }) | 画面の意味そのもの。崩れにくい |
| 高 | label | getByLabel(/メール/) | フォームの意図を確認できる |
| 中 | text | getByText('購入する') | 文言変更には注意 |
| 中 | test id | getByTestId('checkout-submit') | 文言がよく変わる箇所の保険 |
| 低 | CSS構造 | .card:nth-child(3) | レイアウト変更で即壊れる |
data-testid は「逃げ」だと思われがちですが、違います。決済ボタン、ログアウト、多言語で文言が変わる箇所では、むしろ安定した契約になります。ただし全部に付けるとテストが実装の細部に張り付きすぎる。普段は role と label、どうしても無理な所だけ test id、というのが僕の落としどころです。ロケータの種類はPlaywright公式のLocatorsが詳しいです。
ログインはstorageStateで一度だけ
ログインが必要なページのテストを、毎回ログイン画面から始めるのは三重に損です。遅い。ログイン画面を直すと全テストが連鎖して落ちる。さらに二要素認証やbot対策に引っかかる。
Playwrightの storageState は、認証済みのCookieやlocalStorageを1回だけファイルに保存し、後続のテストで使い回す仕組みです。これでログインは「最初の1回」だけになります。
保存したファイルには本物のセッション情報が入るので、playwright/.auth は必ず .gitignore に入れてください。 これを忘れてコミットすると、認証情報がリポジトリに残ります。
// tests/e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
const authFile = path.resolve('playwright/.auth/user.json');
const email = process.env.TEST_EMAIL;
const password = process.env.TEST_PASSWORD;
setup('ログイン状態を保存する', async ({ page }) => {
// 認証情報が無いときはスキップ(CIで秘密が無くても落とさない)
setup.skip(!email || !password, 'TEST_EMAIL と TEST_PASSWORD を設定してください');
await page.goto('/login');
await page.getByLabel(/email|メール|e-mail/i).fill(email!);
await page.getByLabel(/password|パスワード/i).fill(password!);
await page.getByRole('button', { name: /log in|sign in|ログイン/i }).click();
// ログイン後のURLになるまで auto-wait で待つ
await expect(page).toHaveURL(/dashboard|account|admin/);
fs.mkdirSync(path.dirname(authFile), { recursive: true });
await page.context().storageState({ path: authFile }); // ここで状態を保存
});
ここでもClaude Codeに丸投げしないのが大事です。「テストアカウントは本番ユーザーと分ける」「保存ファイルはコミットしない」「権限の強い管理者をCIに置かない」を条件として渡します。認証は便利さとリスクが背中合わせなので、ここだけは人間が握ります。公式の手順はAuthenticationにあります。
コピペで動く最小構成
ここまでの方針を1セットにした、そのまま動く設定とテストです。Astro、Next.js、Remixなどでも baseURL と起動コマンドを変えれば流用できます。
まず入れます。
cd site
npm i -D @playwright/test
npx playwright install
mkdir -p tests/e2e
設定ファイル。ローカルはretry 0、CIだけretry 2にして、CIではTraceとスクリーンショットを厚く残すのがポイントです。fixture(テストの前提を組み立てる仕組み)としての projects で、デスクトップとモバイルの両方を回します。
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
const baseURL = process.env.BASE_URL ?? 'http://127.0.0.1:4321';
export default defineConfig({
testDir: './tests/e2e',
timeout: 30_000,
expect: { timeout: 5_000 }, // assertionのauto-wait上限
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0, // ローカルは即直す、CIだけ再試行
reporter: process.env.CI ? [['html'], ['github']] : 'html',
use: {
baseURL,
headless: true, // CIでもローカルでもheadlessで回す
trace: 'on-first-retry', // 落ちて再試行した時だけ録画
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'desktop-chrome', use: { ...devices['Desktop Chrome'] } },
{ name: 'mobile-safari', use: { ...devices['iPhone 13'] } },
],
});
テスト本体。ユーザーが実際にやる動き(記事を開く→教材へ進む)を、role と auto-wait だけで書いています。waitForTimeout もCSSセレクタも一切ありません。
// tests/e2e/revenue-flow.spec.ts
import { test, expect } from '@playwright/test';
const articlePath = '/blog/claude-code-playwright-testing/';
test.describe('記事から教材への導線', () => {
test('読者が記事から教材ページへ進める', async ({ page }) => {
await page.goto(articlePath);
// 見出しがユーザーに見えていることを、待ちながら確認
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
// 「教材」リンクをクリック。押せる状態まで auto-wait が待つ
await page.getByRole('link', { name: /教材|products/i }).first().click();
await expect(page).toHaveURL(/\/products\/?$/);
await expect(page.getByRole('main')).toBeVisible();
});
test('スマホ幅で本文が横にはみ出さない', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(articlePath);
// 横スクロールの発生を「画像」ではなく「数値」で落とす
const widths = await page.evaluate(() => ({
viewport: window.innerWidth,
doc: document.documentElement.scrollWidth,
}));
expect(widths.doc, JSON.stringify(widths)).toBeLessThanOrEqual(widths.viewport + 2);
});
});
実行はこれだけです。
npx playwright test
スマホ表示のはみ出しを画像ではなく数値(scrollWidth)で落としているのがコツです。画像比較はフォントや広告枠で揺れてフレーキーになりがちなので、合否は数値、画像はレビュー資料、と役割を分けます。レイアウト崩れそのものの直し方はClaude Codeでレスポンシブデザインを実装する実務ガイドにまとめてあります。
Claude Codeにシナリオを渡してE2Eを書かせる
ここがこの記事の本題かもしれません。Claude Codeは「テスト書いて」だと、固定待ちと壊れやすいセレクタを平気で混ぜてきます。コードベースは読めても、品質基準までは読めないからです。
僕は依頼を「ユーザーシナリオ + 禁止事項 + 触ってよい範囲」の3点セットで渡すようにしました。これだけで出てくるコードが落ち着きます。
既存のAstroサイトを読み、Playwright E2Eを追加してください。
ユーザーシナリオ(これを再現する):
1. 読者が記事 /blog/claude-code-playwright-testing/ を開く
2. 本文を読み、「教材」CTAをタップして /products/ へ進む
3. 390px幅のスマホでも、本文・表・コードブロックが横にはみ出さない
禁止事項:
- page.waitForTimeout() を使わない(auto-waitとexpectで待つ)
- CSSクラスやnth-childに依存しない(getByRole / getByLabel を使う)
- ログインが要るなら storageState を使い、毎回UIログインしない
制約:
- 変更してよいファイルは playwright.config.ts と tests/e2e/** だけ
- 実装後に npx playwright test を実行し、落ちたら Trace Viewer の内容で原因を説明する
ポイントは「どの失敗を検出したいか」まで書くことです。レビューするときも、コードの量ではなく、セレクタ・待ち方・データ準備・落ちた時の証跡を見ます。依頼の型はPlaywright自身が公式に出しているGenerating testsやClaude Code common workflowsも参考になります。テスト全体の中でE2Eをどこに置くかはClaude Codeでテスト戦略を作る実践ガイドで整理しています。
CIはheadless + retry + Traceをセットにする
ローカルで緑でも、CIは別環境です。CPUもネットワークも不安定。だからCIでは3点を必ずセットにします。
- headless で回す。画面なしブラウザのほうが速く、CIのデフォルトに合う。
- retry を2回まで。ただし「隠す」ためではない。Playwrightはretryで通ったテストをflakyとして報告するので、「不安定なまま通った」事実が記録に残る。
- Trace と HTMLレポートを保存。落ちた瞬間のDOM・スクリーンショット・ネットワークを後から再生できる。
# .github/workflows/playwright.yml
name: Playwright E2E
on:
pull_request:
push:
branches: [main]
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: site
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: site/package-lock.json
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run build
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: always() # 落ちても証跡を必ず残す
with:
name: playwright-report
path: site/playwright-report
retention-days: 7
CIが赤くなったら、ログだけ見て当てずっぽうで直さないこと。if: always() で保存したレポートを開いて、Trace Viewerで再生します。CI全体の組み方はClaude CodeでCI/CDパイプラインを構築する手順に分けてあります。設定の詳細はPlaywright公式のContinuous Integrationが一次情報です。
Trace Viewerで「落ちた瞬間」を再生する
フレーキーを本気で潰すなら、Trace Viewerは避けて通れません。これは、操作1ステップごとのDOMスナップショット、スクリーンショット、ネットワーク、コンソールログを時系列で再生できるツールです。trace: 'on-first-retry' にしておくと、全テストで重い録画をせず、落ちて再試行したテストだけ証跡を残せます。
# 手元で全部録画して調べたいとき
npx playwright test --trace on
npx playwright show-report
npx playwright show-trace test-results/<パス>/trace.zip
Trace Viewerを開くと、「クリックしようとした瞬間、ボタンがまだローディング中だった」「APIが返る前にassertionが走った」みたいな、ログだけでは絶対に見えない原因が一目でわかります。僕が月曜の朝に溶かした時間は、ここを最初に見ていれば30分で済んでいました。使い方はPlaywright公式のTrace Viewerにあります。
Claude Codeに直してもらうときも、「直して」だけではダメで、次の3点を貼ります。
- 落ちたテスト名
- Trace Viewerで見えた、落ちる直前の画面の状態
- 本来やってほしかったユーザーの動き
これを渡すと、Claude Codeは「待ち時間を伸ばすだけ」の安易な修正ではなく、ロケータの改善、APIのモック、データ準備、UI側のアクセシビリティ修正まで提案してくれます。
僕がやらかした失敗3つ
正直に書きます。最初に書いたE2Eは、自分でフレーキーを量産していました。
ひとつ目は冒頭の waitForTimeout 地獄。描画が間に合わない不安から、あちこちに await page.waitForTimeout(1000) を撒いた結果、テスト全体が遅くなった上に、CIが混む日だけ落ちる。Web-first assertionに全部置き換えたら、速くなって、しかも安定しました。
ふたつ目は セレクタをClaude Code任せにしたこと。.card:nth-child(3) をそのまま採用したら、デザイナーがカード順を入れ替えた翌日に全滅。今は依頼文で「role と label を使え、nth-child禁止」と最初に縛っています。
みっつ目は 画像比較だけで合否を決めたこと。スクリーンショット比較は便利ですが、フォントの差や広告枠、日付表示でピクセルが揺れて、これ自体がフレーキーになりました。横スクロールやCTA表示は数値で落とし、画像はあくまで目視レビュー用、と割り切ってから落ち着きました。
よくある質問
Q. waitForTimeout を完全にゼロにできますか?
ほぼできます。要素の表示・URL遷移・テキスト変化は、すべて expect(locator).toBeVisible() などのWeb-first assertionで待てます。外部の重いアニメーション完了をどうしても待ちたい時だけ例外ですが、その場合も時間ではなく「要素の状態」で待つほうが安定します。
Q. ロケータは全部 data-testid にすればフレーキーは消えますか?
消えません。data-testid はDOM構造の変化には強いですが、要素が表示される前にクリックすればやはり落ちます。安定の本体はauto-waitとWeb-first assertionで、data-testid は文言が変わる箇所の補助、という位置づけが現実的です。
Q. CIのretryは何回が適切ですか? 2回が無難です。0だと偶発的なネットワーク揺れで赤くなり、5回などにするとフレーキーを隠してしまいます。2回にして、retryで通ったテストはflaky報告とTraceを見て根本原因を直す、という運用が回しやすいです。
Q. ローカルでheadedにしてCIだけheadlessにすべき?
デバッグ中は --headed や --ui で画面を見ると速いですが、合否判定はローカルもCIもheadlessで揃えるのがおすすめです。環境差を減らすほどフレーキーは出にくくなります。
Q. Claude Codeに最初から完璧なE2Eを書かせるコツは?
「テスト書いて」ではなく、ユーザーシナリオ・禁止事項(waitForTimeoutとCSSセレクタ)・触ってよいファイル範囲をセットで渡すことです。期待する失敗検出まで言語化すると、レビューが一気に軽くなります。
実際に試した結果
冒頭の「月曜の朝に赤くなるCI」は、原因の8割が waitForTimeout とCSSセレクタでした。この2つを getByRole + Web-first assertionに置き換えただけで、再実行ガチャはほぼ消えました。残った1〜2割は、Trace Viewerで「落ちる直前の画面」を見れば、データ準備不足かAPIの待ち忘れのどちらかで、当てずっぽうの修正がなくなりました。
そして一番効いたのは、Claude Codeへの依頼を「テスト書いて」から「このシナリオを、waitForTimeout禁止・role優先で」に変えたことです。AIが賢くなるのを待つより、渡す指示に門番(禁止事項)を一つ置くほうが、E2Eは早く安定します。同じ発想で単体テストを締めたい人はVitest上級ガイドへ、自分のプロンプトを整えたい人は教材一覧を覗いてみてください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeのチーム利用でコストが読めない時に作る予算ログ
チーム導入前に、誰が何に使い、どの成果が出たかを見える化する予算ログの作り方。
コミット前の3分チェック: Claude Codeが触った範囲を確認してから確定する
Claude Codeが勝手に広げた変更を、コミット前に3分で見抜く確認手順。差分の範囲、検証ログ、ステージするファイルの絞り込みを順番に解説します。
Claude Codeをチーム導入する前に作る「リスク台帳」の中身
Claude Codeを個人実験で終わらせずチーム導入するための、権限・CI・公開の事故を防ぐリスク台帳の作り方を実例とコードで解説します。