AIに禁じるべきE2Eテストの1秒待機

はじめに
AIにE2Eテストの修正を頼むと page.waitForTimeout() を追加してきます。一度許容すると全テストに蔓延し、100テストケースで300秒以上の無駄な待機が発生します。
CLAUDE.mdで明示的に禁止し、expect(element).toBeVisible() などの状態ベース待機に統一することで解決できます。
E2Eテストでこんなことありませんか?
- AIにテスト修正を頼んだら
waitForTimeout(1000)が追加された。テストは通ったがそのままマージした。 - 別のテストでも同じパターンが繰り返され、気づけばスイート全体に根拠のない待機が蔓延していた。
- テスト実行時間が倍になり、CIが遅くなり、フレーキーテストが増えた。
この記事をお勧めしない人
- テストが通れば、実行時間は気にしないという人。
- AIが提案するコードは、基本的にそのまま採用するという人。
- 「waitForTimeout」が何を意味するか、考えたことがない人。
もし一つでも当てはまらないなら、読み進める価値があるかもしれません。
`waitForTimeout` を許容し続けると
- クリック後1秒・ページ遷移後1秒といった根拠のない待機が積み重なり、CIが常時5分超かかるようになる。
- 「これくらい待てば通る」という祈りの待機は、CI負荷が高いときにフレーキーテストになる。
- テストが遅くなるほど「テストを回すのが面倒」という空気が蔓延し、テストカバレッジが下がる。
状態ベース待機への移行という明るい未来
- この記事を読めば、
waitForTimeoutを構造的に排除し、expect(el).toBeVisible()等の状態ベース待機に統一する設計思想が手に入る。 - 具体的には、CLAUDE.mdへの禁止ルール記述例と、Playwright待機メソッドの対応表が手に入る。
- この方法は、このブログのE2Eテストで実証済みで、フレーキーテストをゼロにした。
私も同じでした
このブログでModalのフォーカストラップテストが落ちたとき、AIが waitForTimeout(100) を追加してきました。テストは通りましたが、この1回を許容した瞬間に蔓延が始まると判断し、即座にCLAUDE.mdで禁止しました。代替として expect(modal.locator('input').first()).toBeFocused() を使うパターンを明示したことで、以後AIは状態ベース待機を使うようになりました。
概要
AIにE2Eテストの修正を頼むと、高確率でwaitForTimeoutを追加してきます。これは「テストを通す」という目的には合理的ですが、テストスイート全体のパフォーマンスを破壊します。
この記事では、waitForTimeoutがなぜ危険なのか、どう禁止すべきか、代替手段は何かを解説します。
発生環境の特徴
- テスト種別 : E2Eテスト(エンドツーエンドテスト)
- 問題のパターン : 時間ベース待機の蔓延
- 影響範囲 : テストスイート全体のパフォーマンス
問題の発見 ― AIが書いた「とりあえず待機」
モーダルのフォーカス制御をテストしていました。キーボード操作でフォーカスがモーダル外に逃げる。実装は正しいはずなのに。
AIに修正を頼んだところ、「100ミリ秒待機するコードを追加しました」と返ってきました。
テストは通りました。 しかし、これは解決ではありません。
なぜAIは「待機」に走るのか
AIの思考回路
AIは「テストを通す」ことを目的としています。非同期処理が原因でテストが落ちているなら、待てばいい。論理的に正しい。
問題: 非同期処理が完了する前にアサーションが走る
解決: 待てばいい → waitForTimeoutAIにとって、これは最短で目的を達成する合理的な判断です。
AIが見ていないもの
しかし、AIは以下を見ていません。
- テストスイート全体のパフォーマンス : 1テストの100msが、100テストで10秒になる
- プロジェクトの長期的な健全性 : 一度許容すると、あらゆる場所に伝染する
- 待機時間の根拠 : なぜ100ms?なぜ1秒?根拠はない
個別最適が全体最悪を生む。 これがAIの構造的な限界です。
塵も積もれば ― 待機時間の累積的害悪
蔓延のパターン
一度時間ベース待機を許容すると、以下のように伝染します。
- ログインテスト: ログイン後に1秒待機
- ダッシュボードテスト: ページ遷移後に1秒待機
- 設定テスト: クリック後に0.5秒待機
- モーダルテスト: モーダル操作後に1秒待機
これらすべてに「なぜその時間なのか」という根拠はありません。単に「これくらい待てば通る」という祈りです。
累積的なダメージ
| テスト数 | 平均待機/テスト | 合計待機時間 |
|---|---|---|
| 10 | 2秒 | 20秒 |
| 50 | 2秒 | 100秒(1分40秒) |
| 100 | 3秒 | 300秒(5分) |
| 200 | 3秒 | 600秒(10分) |
10分の無駄な待機。 テストを回すたびに10分のロスです。1日10回テストを回せば、100分。1週間で8時間以上。
これが「塵も積もれば」の正体です。
フレーキーテストの温床
さらに悪いことに、時間ベースの待機は フレーキーテスト (たまに落ちるテスト)の温床です。
- 開発環境では100msで十分だが、CIでは足りない
- CIでは1秒で通るが、負荷が高い時は落ちる
- 「なぜか落ちる」テストが増え、テストへの信頼が崩壊する
私が設計した禁止ルールと代替パターン
この問題を解決するため、私はプロジェクトルールでwaitForTimeoutを明示的に禁止し、状態ベース待機への移行を設計しました。
具体的には、以下の戦略でテストの堅牢性を確保します:
- プロジェクトルールでの明示的禁止 : CLAUDE.mdに禁止ルールを追記し、AIが提案できないようにする
- 代替パターンの提示 : 状態ベースの待機メソッドを一覧化し、具体的な選択肢を明示する
- 自動検出の導入 : リントルールで
waitForTimeoutを検出し、コミット前にブロックする
このアプローチにより、単に「使わないで」と伝えるのではなく、 構造的に使えなくする という、より高度なプロジェクト設計を実現しました。
達成した成果
| 改善項目 | Before | After |
|---|---|---|
| 待機方式 | 時間ベース(祈り) | 状態ベース(事実) |
| テスト実行時間 | 100テストで300秒の無駄な待機 | 必要最小限の待機のみ |
| テストの信頼性 | フレーキーテストが頻発 | 環境に依存しない安定性 |
その結果、 「テストを通す」という個別最適から「テストスイート全体の健全性」という全体最適へ転換する ことに成功しました。
AIに「waitForTimeoutを追加しました」と言われると、高確率で以下のような状況になります:
- 「テストが通ったから良い」と判断し、そのままマージする
- 別のテストでも同じパターンが繰り返される
- 気づけばテストスイート全体に「待機の癌」が広がっている
しかし、これは 対症療法 です。一時的にテストは通りますが、「なぜその時間なのか」という根拠がないため、環境が変わると途端にフレーキーになります。
根本原因は、 AIが個別最適に走る ことにありました。AIは「このテストを通す」ことしか見ませんが、人間は「テストスイート全体のパフォーマンス」や「長期的な保守性」という、より高次の制約を満たす必要があります。
ここから先は、AIが絶対に提案しない 「状態ベース待機」という設計パターン の全貌と、プロジェクトルールでwaitForTimeoutを構造的に禁止する具体的手順、Playwrightが提供する代替メソッドの完全な一覧、そして実際のリントルール実装コードを、すべて公開します。
このルールと実装パターンをコピーすれば、時間ベース待機の蔓延ループを回避し、 初回から堅牢なE2Eテストスイート を実現できます。私が実践で確立した禁止ルールの書き方と、実装済みの代替パターン集を、ここで全て公開します。
構造的解決 ― 状態ベース待機
では、実際に私がCLAUDE.mdに追記した禁止ルールの具体的な記述と、Playwrightが提供する状態ベース待機メソッドの完全な対応表、そして実際に使用したフォーカストラップの修正コードを公開します。また、AIが追加した実際の問題コードと、それを状態ベース待機に修正した具体的なdiffも公開します。
このルールと実装パターンをそのままコピーすれば、「どの待機メソッドを使うべきか」を毎回調べることなく、 再現可能な堅牢なE2Eテスト を実現できます。また、なぜ時間ベース待機が危険なのか、状態ベース待機がフレーキーテストを防ぐ仕組み、そしてリントルールによる自動検出の実装方法も解説します。
使用した技術スタック
- テストフレームワーク : Playwright
- 問題のAPI:
page.waitForTimeout() - 代替パターン :
expect().toBeVisible(),expect().toBeFocused()等 - プロジェクト規範 : CLAUDE.md
AIが追加した問題コード(実例)
// AIが追加したコード
await modalTrigger.click();
const modal = page.locator('[role="dialog"]');
await expect(modal).toBeVisible();
// Wait for focus trap to be set up
await page.waitForTimeout(100); // ← AIの「解決策」
await page.keyboard.press('Tab');問題点 :
- なぜ100ms?根拠なし
- 環境によって足りない可能性(フレーキーテストの温床)
- テストスイート全体に蔓延する危険性
テストスイート全体に蔓延した時間ベース待機の実例
// テストスイート全体に蔓延する「待機の癌」
// ログインテスト
await loginButton.click();
await page.waitForTimeout(1000); // ログイン後の待機
// ダッシュボードテスト
await page.goto('/dashboard');
await page.waitForTimeout(1000); // ページ遷移後の待機
// 設定テスト
await settingsLink.click();
await page.waitForTimeout(500); // クリック後の待機
// モーダルテスト
await modal.locator('button').click();
await page.waitForTimeout(1000); // モーダル操作後の待機これらの待機は全て「祈り」です。環境やサーバーの負荷によって、いつでもフレーキーになりえます。
原則:「時間」ではなく「状態」を待つ
// ❌ 時間ベース(祈り)
await page.waitForTimeout(1000);
await expect(modal).toBeVisible();
// 状態ベース(事実)
await expect(modal).toBeVisible(); // 表示されるまで自動で待つPlaywrightのexpectは、条件が満たされるまで自動でリトライします。デフォルトで5秒間リトライし続けます。 待機時間を指定する必要はありません。
代替パターン一覧
| 待ちたいこと | ❌ 時間ベース | 状態ベース |
|---|---|---|
| 要素の表示 | waitForTimeout(1000) |
expect(el).toBeVisible() |
| 要素の非表示 | waitForTimeout(1000) |
expect(el).not.toBeVisible() |
| フォーカス | waitForTimeout(100) |
expect(el).toBeFocused() |
| テキスト | waitForTimeout(500) |
expect(el).toContainText('...') |
| URL遷移 | waitForTimeout(1000) |
page.waitForURL('/path') |
| APIレスポンス | waitForTimeout(2000) |
page.waitForResponse(url) |
| ネットワーク安定 | waitForTimeout(1000) |
page.waitForLoadState('networkidle') |
フォーカストラップの修正例
// Before: 時間ベース(祈り)
await modalTrigger.click();
const modal = page.locator('[role="dialog"]');
await expect(modal).toBeVisible();
- await page.waitForTimeout(100);
await page.keyboard.press('Tab');
// After: 状態ベース(事実)
await modalTrigger.click();
const modal = page.locator('[role="dialog"]');
await expect(modal).toBeVisible();
+ await expect(modal.locator('input').first()).toBeFocused();
await page.keyboard.press('Tab');「フォーカスが設定された」という事実を確認してから、次に進む。 これが状態ベース待機です。
プロジェクトルールで禁止する
CLAUDE.mdへの追記
AIに「使ってはいけない」と教えるために、CLAUDE.mdに以下を追記します。
## E2Eテストルール
### 禁止事項
- `page.waitForTimeout()` の使用は 原則禁止
- 例外を設ける場合は、コードレビューで理由を説明すること
### 禁止の理由
1. 待機時間の根拠がない(なぜ100ms?なぜ1秒?)
2. 累積的にテスト実行時間を肥大化させる
3. 環境によって必要な待機時間が異なり、フレーキーテストの原因になる
### 代替手段(状態ベース待機)
- `expect(element).toBeVisible()` - 要素の表示を待つ
- `expect(element).toBeFocused()` - フォーカスを待つ
- `expect(element).toContainText('...')` - テキストを待つ
- `page.waitForURL('/path')` - ページ遷移を待つ
- `page.waitForResponse(url)` - APIレスポンスを待つ
- `page.waitForLoadState('networkidle')` - ネットワーク安定を待つリントルールでの検出(発展)
ESLintやカスタムスクリプトでwaitForTimeoutを検出することも可能です。
// scripts/lint-e2e-no-timeout.js
const files = glob.sync('tests/e2e//*.ts');
for (const file of files) {
const content = fs.readFileSync(file, 'utf-8');
if (content.includes('waitForTimeout')) {
console.error(`❌ ${file}: waitForTimeout is banned`);
process.exit(1);
}
}学んだこと・まとめ
技術的な学び
- waitForTimeoutは麻薬 : 一度許容すると蔓延する
- 時間ベースの待機には根拠がない : なぜその時間なのか説明できない
- 状態ベースの待機を使え : Playwrightのexpectは自動でリトライする
AIとの協調における学び
- AIは個別最適に走る : テストスイート全体のパフォーマンスは見ていない
- 禁止ルールを明示せよ : CLAUDE.mdに書かなければ、AIは知らない
- 「使ってはいけない」を教えることも設計 : 許可だけでなく禁止も重要
今後のベストプラクティス
- プロジェクトルールで禁止 : waitForTimeoutを原則禁止する
- 代替パターンを提示 : 何を使えばいいか明確にする
- リントで検出 : 可能であれば自動検出する
「とりあえず1秒待つ」は祈りでしかない。 祈りでテストを書くな。事実で書け。
関連リソース
- Playwright: Auto-waiting - Playwrightの自動待機機構
- Playwright: Assertions - expectの自動リトライ
waitForTimeoutの禁止と同様に、AIがテストを「とりあえず通す」ために生み出すもう一つの問題がハードコードされたデータ依存です。spec.yamlをSSOTとしてE2Eテストデータを一元管理し、記事追加でテストが壊れなくなった記録では、件数ベースのテストを存在確認ベースに移行する手法を解説しています。また、UIの文言変更でテストが壊れる問題はextractTestIdパターンで、環境起因のフレーキーテストはWrangler×Windowsでテストが間欠的に失敗した原因で解説しています。
