↓ メインコンテンツへスキップ

Playwright UI Testing Testing Best Practices

Playwrightの「Test timeout of 30000ms exceeded.」:まず終わらない待機を探す

Playwrightの「Test timeout of 30000ms exceeded.」が実際に何を測っているのか、私が確認する順番で見たよくある原因、止まったステップの見つけ方、そしてタイムアウトを延ばすのが間違いになる場合をまとめました。自分のテストスイートで実際に起きた失敗、5つのテストと名前の変わったテストID 1つの話も添えています。

Playwrightの「Test timeout of 30000ms exceeded.」:まず終わらない待機を探す

Test timeout of 30000ms exceeded. は、Playwrightのテストが30秒の持ち時間を超えたという意味です。30秒はPlaywright Testのデフォルトで、この持ち時間にはテスト本体、beforeEach フック、フィクスチャのセットアップがまとめて含まれます。メッセージがステップではなくテストを名指しするのは、クリックや入力、ページ遷移にはデフォルトで専用のタイムアウトがないからです。どれだけかかっても待ち続け、テストタイムアウトはたまたま最初に切れる時計にすぎません。

つまり、このエラーが本当に速度の問題であることはまれです。たいていは、どこかのステップが決して起こらないことを待っています。私が確認する順に、原因と対処を並べるとこうなります。

  1. ロケーターが何にも一致せず、click() や fill() がテストの持ち時間が尽きるまで待ち続けている。よくある理由は、テストIDの変更、テキストの変更、手前に被さったダイアログです。ロケーターを直します。
  2. ページ遷移や waitForURL() が、いつまでも来ないページを待っている。リダイレクトやログイン画面、初回起動の画面のせいで、アプリが別の場所へ行ってしまったケースです。待つ対象を直します。
  3. セットアップが本当に遅い。初回リクエストでようやくコンパイルを始める開発サーバー、ゼロスケールから起き上がるバックエンド、beforeEach のたびにUIで行うログインなどです。環境を直すか、セットアップを1回で済ませます。
  4. テストが単純に長い。そのときに限って、test.slow() か test.setTimeout() でそのテストに時間を足します。

どれであっても、最初にやることは、時間切れの瞬間にまだ動いていたステップを見つけることです。次にやるのが actionTimeout の設定で、そうしておけば次の失敗ではエラーのほうがステップを教えてくれます。

30秒に含まれるもの
#

テストタイムアウトは、フィクスチャのセットアップ、beforeEach フック、テスト関数が共有する1つの持ち時間です。テストが終わると、afterEach フックとフィクスチャのティアダウンには、同じ長さの持ち時間が別に与えられます。beforeAll と afterAll も同じ値を受け取りますが、どのテストとも時間を共有せず、失敗したときも専用のメッセージを出します。

この持ち時間の中で、自分の時計を持っているのはアサーションだけです。await expect(locator).toBeVisible() はデフォルトで5秒たつと諦め、何を探していたのかを正確に教えてくれます。アクションとページ遷移はそうではありません。actionTimeout と navigationTimeout のデフォルトは無制限です。このエラーがこれほど曖昧な理由は、すべてここにあります。決して現れない要素へのクリックは失敗しません。ほかの何かがテストを終わらせるまで、行儀よく待ち続けるだけです。

実行を終わらせうるタイムアウトをすべて、それぞれが出すメッセージと一緒に並べました。メッセージは、私のプロジェクトに入っている中で最も古い1.58と最も新しい1.63からそのまま写しています。

タイムアウトデフォルト設定する場所出力されるメッセージ
テスト(本体、beforeEach、フィクスチャのセットアップ)30,000 ms設定ファイルの timeout、--timeout、test.setTimeout()、test.slow()Test timeout of 30000ms exceeded.
beforeAll / afterAllテストタイムアウトと同じ値フック内で test.setTimeout()"beforeAll" hook timeout of 30000ms exceeded.
アサーション(expect)5,000 ms設定ファイルの expect: { timeout }、アサーションごとの { timeout }expect(locator).toBeVisible() failed … Timeout: 5000ms
アクション(click、fill)なしuse: { actionTimeout }、アクションごとの { timeout }TimeoutError: locator.click: Timeout 10000ms exceeded.
ページ遷移(goto、waitForURL)なしuse: { navigationTimeout }、呼び出しごとの { timeout }TimeoutError: page.waitForURL: Timeout 15000ms exceeded.
実行全体なし設定ファイルの globalTimeoutTimed out waiting 3600s for the test suite to run
webServer の起動60,000 mswebServer: { timeout }Timed out waiting 60000ms from config.webServer.

アクション、ページ遷移、実行全体の行にはデフォルト値がないので、表の数字は自分で設定した値が入る場所です。

5つのテストと、名前の変わったテストID
#

2月、私が作っている見積もりアプリのテストスイートで、5つのテストが1回の実行の中でまったく同じように失敗しました。このスイートは、開発サーバーが配信するアプリのWeb版を、ワーカー1つで、シードデータを入れ直したローカルのバックエンドに対して動かしています。5つのうち最初の1つを、少し削って載せます。

  1) [default] › tests/templates.spec.ts:50:7 › Templates › should edit a template ───

    Test timeout of 30000ms exceeded.

    Error: locator.click: Target page, context or browser has been closed
    Call log:
      - waiting for locator('[data-testid="template-item"]').filter({ hasText: 'E2E Edit Template' }).locator('[data-testid="edit-template"]')


      57 |       .locator('[data-testid="template-item"]')
      58 |       .filter({ hasText: "E2E Edit Template" });
    > 59 |     await item.locator('[data-testid="edit-template"]').click();
         |                                                         ^
      60 |     await page.waitForTimeout(500);

Target page, context or browser has been closed の行だけ見ると、ブラウザが落ちたように読めます。違います。足元でページが閉じられたときに、アクションが出すメッセージです。30秒が尽きてPlaywrightがテストを片付け、まだ待っていたクリックが、失ったページのことを報告したわけです。組み込みの page フィクスチャを使っていれば、この行は Error: locator.click: Test timeout of 30000ms exceeded. のようにタイムアウトをもう一度伝えていたはずです。このテストは beforeAll で自前のブラウザコンテキストを開いているので、クリックには汎用のメッセージが届きました。

役に立つのはコールログです。クリックは [data-testid="template-item"] を待っていました。その10時間前、私は一覧の行コンポーネントのテストIDを template-item から template-item-${name} に変えていました。ネイティブアプリのE2Eテストが、名前で特定の行を選べるようにするためです。= を使った属性セレクターは値全体が一致しないとマッチしないので、Webのテストはページのどこにも存在しない要素を探していたことになります。アクションタイムアウトもないので、そのクリックはテストの持ち時間が尽きるまで待つしかありませんでした。30秒の失敗が5つで2分30秒。実行全体は3.8分でした。

私が最初に足したのはリトライでした。30分もしないうちに設定には retries: process.env.CI ? 2 : 1 が入り、そこに付けたコメントには今も “for flaky browser crashes”(ときどき起きるブラウザのクラッシュ対策)と書いてあります。次の実行では、同じ5つのうち4つがリトライも含めてまた失敗し、実行時間は3.8分から6.4分に延びました。これらのdescribeブロックはシリアルモードで動きます。1つが失敗するとグループの残りはスキップされ、リトライはグループを最初からやり直します。そのせいで、壊れたクリックはどれも30秒を2回ずつ待ちました。毎回同じ理由で失敗するテストは、リトライでは救えません。代償を2回払うだけです。

修正は前方一致でした。

const item = page
  .locator('[data-testid^="template-item-"]')
  .filter({ hasText: "E2E Edit Template" });
await item.locator('[data-testid="edit-template"]').click();

もっと広い教訓は、テストIDも一種の契約だということです。私には、このID 1つに頼るテストスイートが2つあり、どちらもお互いの存在を知りませんでした。Playwrightのベストプラクティスのガイドが、私が使ったようなCSSセレクターよりも getByRole() のようなユーザーから見える属性に基づくロケーターを勧めているのも、ユーザーが気づきもしないDOMの変更でテストが壊れないようにするためです。

止まったステップを見つける
#

まずコールログを読みます。これは時間切れの瞬間にまだ動いていたステップのもので、waiting for locator(...) の後に何も続いていなければ、ロケーターが最後まで何にも一致しなかったということです。

それで足りなければトレースを開きます。私のアプリのスイート2つ、見積もりアプリのものと、私が作っているマーケットプレイス兼コミュニティアプリのものは、どちらも trace: "on-first-retry" を使っています。最初のリトライのときだけ記録する設定なので、retries が1以上でないと記録されません。リトライを使わないなら "retain-on-failure"、1回だけ試すなら --trace on です。あとはトレースを直接開くか、HTMLレポートから開きます。

npx playwright show-trace test-results/<test-folder>/trace.zip
npx playwright show-report

終わらなかったアクションは、一覧の最後のほうでエラーが付いている項目です。それを選んでスナップショットを見てください。Playwrightが待っていた時点のページそのもので、期待していた要素を自分で探せます。私のケースなら、テストが探していないIDを付けた行が、ちゃんとそこにあったはずです。

最近のバージョンは、失敗時のスクリーンショットの隣に error-context.md も書き出します。失敗した瞬間のページをテキストのスナップショットにしたもので、要素が見つからないケースなら、これだけで十分なこともよくあります。

落とし穴が1つあります。--debug はInspectorを開くと同時に、--max-failures=1、--headed、--workers=1 と一緒に --timeout=0 も設定します。--debug で通ったテストは、30秒については何も教えてくれていません。

すべてのアクションに上限を
#

私の設定ファイルには、アクションタイムアウトを設定しているものが1つもありません。だから見つからない要素1つのために、30秒を5回も使ったわけです。use に2行足せば変わります。

import { defineConfig } from "@playwright/test";

export default defineConfig({
  timeout: 30_000,
  expect: { timeout: 5_000 },
  use: {
    actionTimeout: 10_000,
    navigationTimeout: 15_000,
    trace: "retain-on-failure",
  },
});

すると同じ壊れたロケーターが、テストにまだ時間が残っているうちに、こう失敗します。

    TimeoutError: locator.click: Timeout 10000ms exceeded.
    Call log:
      - waiting for locator('[data-testid="template-item"]').filter({ hasText: 'E2E Edit Template' }).locator('[data-testid="edit-template"]')

どのステップで、どのアクションが、どれだけ待ったのかが全部わかります。2つの値はテストタイムアウトより十分小さくしておくのがいいと思います。そうすれば、止まったステップ1つが持ち時間を使い切り、曖昧なメッセージの陰に隠れることはありません。2月の問題は30秒ではありませんでした。あのクリックにかかっていた時計がそれ1つだけだったので、それが鳴ったというだけです。

タイムアウトを延ばすのは長いテストだけ、止まったテストではなく
#

タイムアウトを延ばすのが正解なのは、テストが実際に時間のかかる仕事をしているときです。あるステップが起こらないことを待っているときには不正解です。数字を大きくしても、失敗が遅れて届くだけですから。どちらなのかは、たいていトレースを見ればはっきりします。アクションが1つずつ終わっていて単に数が多いなら長いテスト、1つのアクションが最後まで waiting for のまま止まっているなら止まったテストです。

長いテストなら、スイート全体ではなく、長さがある場所に時間を足します。マーケットプレイスアプリのスイートには、スクリーンショットを撮りながらチャット画面を一通り回るスモークテストが1つあり、このテストには3分を与えています。

test("chat: open, send a message, go back", async ({ page }) => {
  test.setTimeout(180_000);
  // ...
});

describe の中で test.setTimeout() を呼ぶと、グループ内のすべてのテストに効きます。見積もりアプリのスイートの作業コスト(job-cost)のテストが1分をもらっているのは、この方法です。test.slow() は今のタイムアウトが何であれ3倍にし、条件を渡すこともできます。beforeEach の中で testInfo.setTimeout() を使えば、そのフックを通るすべてのテストが延びます。

test("slow in WebKit", async ({ page, browserName }) => {
  test.slow(browserName === "webkit", "This page is slow in WebKit");
  // ...
});

test.beforeEach(async ({ page }, testInfo) => {
  testInfo.setTimeout(testInfo.timeout + 30_000);
});

持ち時間を毎回静かに削っていくものが、もう1つあります。固定のスリープです。見積もりアプリのスイートには、ほぼすべてのspecファイルにまたがって、page.waitForTimeout() の呼び出しが100個以上あります。上の編集テストはクリックにたどり着く前に3秒眠っていますが、どれだけ眠っても助からなかったでしょう。PlaywrightのAPIドキュメントも、ここははっきりしています。“Never wait for timeout in production. Tests that wait for time are inherently flaky."(本番では決して時間で待たないこと。時間を待つテストは本質的に不安定です。)Webファーストアサーションは、もともと必要なだけ待ち、それ以上は待ちません。

// before
await page.locator('[data-testid="save-template"]').click();
await page.waitForTimeout(1500);
await expect(page.getByText("E2E Edited Template")).toBeVisible();

// after
await page.locator('[data-testid="save-template"]').click();
await expect(page.getByText("E2E Edited Template")).toBeVisible();

アサーションのタイムアウトにも同じ注意が要ります。2つのアプリのスイートはどちらも expect.timeout を15秒に上げていて、どちらも本番ビルドではなくアプリの開発サーバーを相手に動いています。余裕はできますが、遅いアサーションが2つ続けば、その2つで30秒を使い切ることもあります。そうなるとテストタイムアウトが先に切れて、また曖昧なメッセージに逆戻りです。

テストの時計ではないとき
#

メッセージに config.webServer が出てくるなら、まだテストは1つも動いていません。Playwrightは webServer のコマンドを起動し、その url が応答するまで待ってからテストを始めます。この待ち時間には別のタイムアウトがあり、デフォルトは60秒です。2つのアプリのスイートは、どちらも開発サーバーに2分を与えています。

webServer: {
  command: "npm run web -- --port 8081",
  url: "http://localhost:8081",
  reuseExistingServer: false,
  timeout: 120_000,
},

テストがローカルのサーバーではなくデプロイ済みの環境に向いているなら、最初のリクエストにどれだけかかるかを確かめてください。使っているホスティング先でようやく測ってみたら、ゼロスケールから起動するマシンが何かを返すまでに25〜33秒かかっていました。最初のクリックの前に、テストの持ち時間のほとんど、あるいは全部です。その話は「ゼロスケールと、再起動のたびに起きる30秒の障害」に書きました。

「No tests found」と出たら
#

No tests found は似て見えますが、タイムアウトではありません。Playwrightが探したものの、実行するものが何もなかったという意味です。デフォルトでは testDir の下で **/*.@(spec|test).?(c|m)[jt]s?(x) に一致するファイルを集め、testDir のデフォルトは設定ファイルがあるフォルダーです。私の設定は設定ファイルの隣の testDir: "./tests" を指しているので、ほかの場所に保存したspecは見えません。コマンドラインの引数はファイルパスに対してマッチさせる正規表現で、だからエラーには “Make sure that arguments are regular expressions matching test files.” という一文が付きます。何も実行せずに、Playwrightが何を集めたかだけを見るにはこうします。

npx playwright test --list

その一覧にspecがないなら、そもそもタイムアウトになりようがありません。Playwrightをこれから導入するところなら、スクリーンショット比較から始めようという私の考えは「小規模チームこそビジュアルテストを」に書いています。