PlaywrightでAuto-waitingを壊してしまう9つの最悪の書き方

PlaywrightのAuto-waitingとは

Playwrightの最大の強みは、要素がクリック可能になるまで自動で待ってくれる「Auto-waiting(自動再試行)」の仕組みです。

この仕組みのおかげで、非同期処理の多い場合でも「テストが時々落ちる」という事態を防ぐことができます。

しかし、従来のSeleniumやPuppeteerなどのテストコードの癖が抜けないままコードを書くと、Playwrightの自動待機を破壊してしまう最悪の書き方になってしまいます。

この記事ではエンジニアがやってしまいがちな「Auto-waitingを壊す9つの書き方」と、その正しい解決策をまとめました。

1. page.waitForTimeout()による時間指定の待機

最もよく見かけるのが待機時間を指定するパターンです。

// ❌️ NG
await page.getByRole('button', { name: '保存' }).click()
await page.waitForTimeout(3000)
await expect(page.locator('#success-msg')).toBeVisible()

page.waitForTimeout()は一律で指定した時間だけ停止させるため、テスト実行時間を無駄に引き延ばす最大の原因になります。

さらに、ローカルPCでは3秒で動いても、負荷のかかるCI環境では4秒かかってテストが落ちる、といった「不安定なテスト」の原因になります。

公式ドキュメントでもデバッグ目的以外での使用は禁止されているのですが、いまだに使用している方が多いです。

Playwrightのexpectは要素がその状態になるまでデフォルトで5秒間自動待機するので、余計な待機コードは一切不要です。

// ✅️ OK
await page.getByRole('button', { name: '保存' }).click()
await expect(page.locator('#success-msg')).toBeVisible()

もしも処理が重くて10秒待ちたい場合は、expectの第2引数に { timeout: 10000 } をミリ秒単位で渡します。

// ✅️ OK
await page.getByRole('button', { name: '保存' }).click()
await expect(page.locator('#success-msg')).toBeVisible({ timeout: 10000 })

Playwrightのテストにはexpectの待機時間とは別に「テスト1件あたりの総制限時間(デフォルト30秒)」があります。

テストファイルの先頭で「test.setTimeout(60000)」のように全体の制限時間も延ばせますが、1件のテストに30秒以上かかるのは長すぎるので、特別な事情がなければ分割してテストすることを推奨します。

2. page.$()やpage.$$()による要素取得

PuppeteerやjQueryのように、HTML要素を直接変数に引っこ抜こうとする書き方です。

// ❌️ NG
const submitButton = await page.$('#submit-btn')
await submitButton.click() // ボタンの描画が1ミリ秒でも遅れたら即エラー

page.$() は、その時点のDOM要素を取得するAPIです。

そのため、JavaScriptのレンダリングを待つためのAuto-waitingが一切働きません。

要素を指定するときは、常に状態を動的に追跡できるLocatorを使用します。

// ✅️ OK
await page.locator('#submit-btn').click()
await page.getByRole('button', { name: '送信' }).click()

3. page.evaluate() による強制クリック

「要素が見つからない」「クリックできない」というエラーに直面したとき、力技で解決しようとして生まれる最悪のコードです。

// ❌️ NG
await page.evaluate(() => {
  document.querySelector('#submit-btn').click()
})

page.evaluate()はPlaywrightのテスト機構をバイパスして、ブラウザの開発者ツールから直接JavaScriptを流し込んでいる状態です。

Playwrightが本来行う「要素が隠れていないか」「アニメーション中でないか」「disabledではないか」といったアクション性チェックをすべて無視するため、バグを見逃す原因になります。

どうしても他の要素に遮られているなどの理由で通常クリックができない場合は、ロケーターのオプションで「force: true」を渡すか、要素が表示される条件を正しく待つようにします。

// ✅️ OK
await page.getByRole('button', { name: '送信' }).click({ force: true })

4. innerText()などによる文字列を取り出してからの比較

innerText()やgetAttribute()などのメソッドは、呼び出された瞬間の値を返します。

その後に画面が更新されても、変数の中身は古いままです。これではアサーションの自動再試行が働きません。

// ❌️ NG
const statusText = await page.locator('.status').innerText()
expect(statusText).toBe('完了') // 非同期で「完了」に切り替わるのを待ってくれない

innerText()などは使わずexpect()の中にLocatorを渡すのが正しい書き方です。

これにより、条件が満たされるまで自動で再試行を繰り返してくれます。

// ✅️ OK
await expect(page.locator('.status')).toHaveText('完了')

5. 無意味なwaitForSelector

要素を待つAPIを個別に呼び出した直後に、同じ要素を操作するコードです。

// ❌️ NG
await page.waitForSelector('#submit-btn')
await page.click('#submit-btn')

一見、慎重で良いコードに見えますが、完全に無意味です。

PlaywrightのLocatorは内部で自動的にwaitForSelector相当の処理を行っているので、コードが冗長になり、可読性と処理速度が悪化します。

Playwrightのlocatorで1行で書きましょう。

// ✅️ OK
await page.locator('#submit-btn').click()

6. isVisible()の戻り値による条件分岐

画面に要素があるかどうかで処理を分けようとして、if文の中に isVisible()を入れてしまう書き方です。

// ❌️ NG
if (await page.locator('#popup-close-btn').isVisible()) {
  await page.locator('#popup-close-btn').click()
}

isVisible()は呼び出されたその一瞬の表示状態を「true」か「false」で返すだけの関数です。

そのため、Auto-waitingは働きません。

「表示されるかもしれないし、されないかもしれない」要素を待つ場合は、デフォルトの時間内に表示されるならそのまま書きます。

デフォルトの時間を超える場合はwaitForを使ってタイムアウトを設定します。

// ✅️ OK
await page.locator('#popup-close-btn').click()
// ✅️ OK
try {
  await page.locator('#popup-close-btn').waitFor({ state: 'visible', timeout: 10000 })
  await page.locator('#popup-close-btn').click()
} catch {
  // 表示されなかった場合は何もしない
}

7. page.waitForURL()の順番ミス

画面遷移を伴うボタンクリックの際、クリックした後に待つコードを書いてしまうパターンです。

// ❌ NG
await page.getByRole('button', { name: 'ログイン' }).click()
await page.waitForURL('**/dashboard')

.click()を実行した瞬間に一瞬で画面遷移が始まった場合、次の行のwaitForURLが実行される頃には、すでに画面遷移が終わっていることがあります。

すると、waitForURLは「これから起こるはずの画面遷移」を永久に待ち続け、タイムアウトでテストが落ちてしまいます。

この場合は.toHaveURL()を使用するのが正解です。

// ✅️ OK
await page.getByRole('button', { name: 'ログイン' }).click()
await expect(page).toHaveURL('**/dashboard')

8. all()を使用した配列のカウント

画面上のリストやカートのアイテム数が、非同期通信によって増減するのを検証したいときにやってしまうミスです。

// ❌ NG
await page.getByRole('button', { name: 'カートに追加' }).click()
const items = await page.locator('.cart-item').all()
expect(items.length).toBe(3); // カートが更新されるのを待ってくれない

all()は呼び出されたその瞬間にDOM上にある要素をJavaScriptの配列として取得します。

配列になってしまったデータに対しては、PlaywrightのAuto-waitingは一切働きません。

この場合は.toHaveCount()を使用するのが正解です。

// ✅️ OK
await page.getByRole('button', { name: 'カートに追加' }).click()
await expect(page.locator('.cart-item')).toHaveCount(3)

9. count()を使用した配列のカウント

count()も呼び出された瞬間の数値を返すだけなので、Auto-waitingが働きません。

配列が3個以上存在するか確認する場合はexpect.poll()で繰り返し実行して、locator.count()の値が3より大きくなるまで再試行します。

// ✅️ OK
await page.getByRole('button', { name: 'カートに追加' }).click()
await expect.poll(() => locator.count()).toBeGreaterThan(3)

ちなみに生成AIだと.toHaveCount({ greaterThan: 3 })で検証できると回答することがありますが、greaterThanのオプションは嘘なので注意が必要です。

// ❌️ NG
await page.getByRole('button', { name: 'カートに追加' }).click()
await expect(page.locator('.cart-item')).toHaveCount({ greaterThan: 3 })

まとめ

PlaywrightでAuto-waitingを壊してしまうすべての原因は、状態が変わるかもしれないデータをプレーンなJavaScriptの値に途中で変換してしまうことです。

常にLocatorの状態のままexpectに渡すことで、Auto-waitingを壊さない安定したテストコードになります。