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は「テストを通す」ことを目的としています。非同期処理が原因でテストが落ちているなら、待てばいい。論理的に正しい。

問題: 非同期処理が完了する前にアサーションが走る
解決: 待てばいい → waitForTimeout

AIにとって、これは最短で目的を達成する合理的な判断です。

AIが見ていないもの

しかし、AIは以下を見ていません。

  1. テストスイート全体のパフォーマンス : 1テストの100msが、100テストで10秒になる
  2. プロジェクトの長期的な健全性 : 一度許容すると、あらゆる場所に伝染する
  3. 待機時間の根拠 : なぜ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を明示的に禁止し、状態ベース待機への移行を設計しました。

具体的には、以下の戦略でテストの堅牢性を確保します:

  1. プロジェクトルールでの明示的禁止 : CLAUDE.mdに禁止ルールを追記し、AIが提案できないようにする
  2. 代替パターンの提示 : 状態ベースの待機メソッドを一覧化し、具体的な選択肢を明示する
  3. 自動検出の導入 : リントルールで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);
  }
}

学んだこと・まとめ

技術的な学び

  1. waitForTimeoutは麻薬 : 一度許容すると蔓延する
  2. 時間ベースの待機には根拠がない : なぜその時間なのか説明できない
  3. 状態ベースの待機を使え : Playwrightのexpectは自動でリトライする

AIとの協調における学び

  1. AIは個別最適に走る : テストスイート全体のパフォーマンスは見ていない
  2. 禁止ルールを明示せよ : CLAUDE.mdに書かなければ、AIは知らない
  3. 「使ってはいけない」を教えることも設計 : 許可だけでなく禁止も重要

今後のベストプラクティス

  1. プロジェクトルールで禁止 : waitForTimeoutを原則禁止する
  2. 代替パターンを提示 : 何を使えばいいか明確にする
  3. リントで検出 : 可能であれば自動検出する

「とりあえず1秒待つ」は祈りでしかない。 祈りでテストを書くな。事実で書け。

関連リソース

waitForTimeoutの禁止と同様に、AIがテストを「とりあえず通す」ために生み出すもう一つの問題がハードコードされたデータ依存です。spec.yamlをSSOTとしてE2Eテストデータを一元管理し、記事追加でテストが壊れなくなった記録では、件数ベースのテストを存在確認ベースに移行する手法を解説しています。また、UIの文言変更でテストが壊れる問題はextractTestIdパターンで、環境起因のフレーキーテストはWrangler×Windowsでテストが間欠的に失敗した原因で解説しています。