LayerX エンジニアブログ

LayerX の エンジニアブログです。

Playwrightの自動待機(Auto-waiting)を使いこなし、保守性の高いテストコードを書こう

こんにちは、LayerX バクラク事業部で勤怠プロダクトを担当しているQAエンジニアの matsu です!

Playwrightで書いたE2Eテストが「時々失敗する」「手元では動くのにCIだと落ちる」といった経験はありませんか? その不安定なテスト(Flaky Test)の原因、テストの「待ち方」にあるかもしれません。 私たちのチームでも最近、このFlakyなテストに悩まされることが増え、調査を進める中でPlaywrightの待機処理に関する知見が溜まってきました。 そこでこの記事では、Playwrightの「自動待機」の仕組みを正しく理解し、明示的な待機をどう使っていくか、私たちが実践しているアプローチを共有できればと思います。

Playwrightにデフォルト搭載されている自動待機機能について

Flakyなテストの主な要因の一つは操作対象の要素がまだ完全に表示されていなかったり、操作可能な状態になっていなかったりするにも関わらず、テストが先に進んでしまうことです。 しかし、Playwrightにはこの問題を解決するために、自動待機の仕組み(Actionability checks)がデフォルトで組み込まれています。そのため、ほとんどのシナリオでは、明示的に待機処理を記述する必要はありません。

例えば、ボタンをクリックすると少し遅れてテキストが表示される、というよくあるUIをテストする場合を考えてみましょう。

良い例👍:Playwrightの自動待機に任せる

// この一行に「ボタンが表示され、クリック可能になるまで待つ」処理が含まれている
await page.getByRole('button', { name: 'データを表示' }).click();

// この一行にも「'#result'に指定のテキストが表示されるまで待つ」処理が含まれている
await expect(page.locator('#result')).toHaveText('こんにちは、Playwright!');

上のコードでは、click()やexpect()の前にwaitForSelectorやwaitForTimeoutのような明示的な待機処理を記述していません。Playwrightが内部で「クリックできる状態か?」「期待するテキストが表示されたか?」を自動でチェックし、条件が満たされるまで待ってくれるため、これだけで安定したテストが成り立ちます。

悪い例👎:不要な待機を追加してしまう

// clickが待ってくれるので、この待機は基本的に不要
await page.waitForTimeout(500);
await page.getByRole('button', { name: 'データを表示' }).click();

// expectが待ってくれるので、この待機は基本的に不要
await page.locator('#result').waitFor(); 
await expect(page.locator('#result')).toHaveText('こんにちは、Playwright!');

もし、それでもテストが不安定になる場合は、上記のような安易な明示的待機を追加するのではなく、まずPlaywrightの自動待機が正しく機能しているかを確認することが重要です。不必要な待機を追加すると、テストが遅くなるだけでなく、根本的な問題を見逃し、誤って「修正された」と判断してしまう可能性もあります。

playwright.dev

明示的な待機処理が必要となる典型的なケース

以下のような状況では、Playwrightの自動待機だけでは対応しきれず、明示的な待機処理が必要になることがあります。

  • ローディング完了後のリセット
    • 画面のローディングが完全に終わる前に操作対象の要素がインタラクティブな状態になるものの、ローディング完了と同時にその操作がリセットされてしまうような場合(例:inputフィールドへの入力が、ローディング完了後に空になってしまう)。
  • クリックはできるが動作が不安定
    • click()操作自体は成功するものの、その後の動作が正しく実行されない場合。
  • 自動待機が効かないメソッド
    • locator.count() のように、要素の数を即座に返すだけで自動待機を行わないメソッドがあります。このようなメソッドを条件分岐などで使用すると、要素が表示される前に評価されてしまい、テストが不安定になることがあります。

アクションによって引き起こされる「結果」を待つ

前述の通りPlaywrightでは対象の要素が操作可能になるまで自動で待機してくれます。そのため、アクションの直前に「要素が表示されるのを待つ」といった明示的な待機処理を入れる必要はほとんどありません。 明示的に待つべきなのは、アクションによってトリガーされた非同期処理やUIの更新が完了するのを待つときです。これを「アクションの結果を待つ」と考えると非常にシンプルになります。

悪い例👎:固定時間で待つ

// 保存ボタンをクリックした後、とりあえず500ms待つ
await page.getByRole('button', { name: '保存' }).click();
await page.waitForTimeout(500); // ネットワークの遅延などで簡単に壊れる

悪い例👎:アクションの前で待つ

await page.getByRole('button', { name: '詳細'}).click();
await page.waitForURL("https://www.example.com");

// ...間に他の操作や処理が入る...

await page.locator('.loading-icon').waitFor({ state: "hidden" });; // なぜここで待つのか、意図が分かりにくい
await page.getByRole('button', { name: '保存' }).click();
await page.locator('.toast-success').waitFor();

良い例👍:期待する「結果」で待つ

await page.getByRole('button', { name: '詳細'}).click();
// clickのアクションの完了を待つという意図がわかりやすい
await page.waitForURL("https://www.example.com");
await page.locator('.loading-icon').waitFor({ state: "hidden" });

// ...間に他の操作や処理が入る...

await page.getByRole('button', { name: '保存' }).click();
await page.locator('.toast-success').waitFor();

このように「アクションの結果、期待される状態になるまで待つ」というアプローチで統一することで、アクションとそのアクションが完了したことを保証する待機処理がセットになっているため、コードの可読性が向上し、メンテナンスがしやすくなります。

待機処理をテストコードから隠す

待機処理は基本的にページオブジェクトファイルに記述し、テストスペックファイルからは隠しましょう。ページオブジェクトを使わない場合でも、メソッドとして共通化することが重要です。

このように待機処理をカプセル化すると、まずスペックファイルからテストの操作手順に不要な情報が排除され、テストの意図がより明確になるためテストの可読性が向上します。さらに、待機処理を含む一連の操作をメソッドにまとめておくことで再利用性が高まり、同じ問題に繰り返し直面することを防ぎます。結果として、将来的な変更にも強い、保守性の高いテストコードを維持できるのです。

invoiceRequests.ts

import { Locator, Page, expect } from "@playwright/test";

export class InvoiceRequests {
  readonly page: Page;
  readonly loadingIcon: Locator;
  readonly journalItems: Locator;

  constructor(page: Page) {
    this.page = page;
    this.loadingIcon = page.locator("span.spinner-border");
    this.journalItems = page.locator("tbody > tr");
  }

  async showJournalDetail(requestNumber: string) {
    await this.journalItems.filter({ hasText: requestNumber }).click();
    // 1. URL遷移直後にローディングアイコンが表示されないので一度表示を待つ
    await this.loadingIcon.waitFor({ state: "visible" });
    // 2. expectのポーリングでローディングアイコンが全て非表示になるまで待つ
    await expect(this.loadingIcon).toHaveCount(0);
  }
}

test.spec.ts

import { test, expect } from '@playwright/test';
import { InvoiceRequests } from "@pages/invoice/expense/requests";
import { InvoiceRequestsId } from "@pages/invoice/expense/requests/id";

test('経費精算申請詳細に申請情報が表示されている', async ({ page }) => {
  const invoiceRequests = new InvoiceRequests(page);
  const invoiceRequestsId = new InvoiceRequestsId(page);

  const requestNumber = '100';

  // 操作:詳細画面を開く(待機処理の詳細は隠蔽されている)
  await invoiceRequests.showJournalDetail(requestNumber);

  // 検証:詳細画面で申請番号が正しいことを確認
  await expect(invoiceRequestsId.requestInfo).toHaveText(requestNumber);
});

「結果を待つ」待機処理の例

「結果を待つ」アプローチにもいくつか方法が存在するので、よく使う具体的な待機処理の例を紹介します。これらを適切に使いなおかつ組み合わせることでテストの安定性が高まります。他にもPlaywrightには待機処理を提供しているメソッドがあるので一例になります。

1. 特定の要素の表示/非表示を待つ
これは、非同期処理の「結果」をUIの状態変化として直接捉える、最も基本的なアプローチです。完了の指標となる要素を正しく特定できていさえすれば、多くのケースで待機処理はこれだけで完結するため、非常に応用範囲の広い方法と言えます。

// ローディングアイコンが非表示になることを持ってロード完了とする
await page.locator('.loading-icon').waitFor({ state: "hidden" });
    
// トーストが表示されることを持ってロード完了とする
await page.locator('.toast-success').waitFor();

2. 特定の要素の数を待つ
検索やフィルタリング機能など、アクションによってリストの要素数が変わるのを待ちたい場合に使います。Playwrightのexpectには、条件が満たされるまで自動でリトライ(ポーリング)する機能があります。この仕組みを利用して、「要素の数が期待通りになるまで待つ」という待機処理を実現できます。

// waitであってテストケースの確認項目ではないが、expectのポーリング機能で待つ
await expect(page.locator("tbody > tr")).toHaveCount(1);

3. URLの遷移を待つ
クリック操作などによって画面遷移が発生し、URLが変わるまで待つ場合に使います。ただし、このメソッドはURLが変更された時点で完了となるため、ページ全体のレンダリングまでを保証するわけではありません。そのため、URLの遷移後にローディングが完了した指標となる要素を待つ処理と組み合わせることで、より安定した待機処理になります。

await page.waitForURL("https://www.example.com");

4. ネットワークのアイドル状態を待つ(非推奨)
PlaywrightのガイドではすでにDISCOURAGED (非推奨)になっており、代わりにページの特定の要素を直接確認することが推奨されています。バックグラウンドで常にリクエストが発生する場合は正しく待機処理ができなかったり、画面のレンダリングは待てないので、基本的には代替手段を検討してください。

await page.waitForLoadState("networkidle");

トラブルシューティングのヒント

  • Playwright Trace Viewerを活用する: Flakyなテストをデバッグする際、テスト実行時に --trace on オプションをつけトレースを有効にすると、テスト失敗時の詳細なレポートが生成されます。
    • DOMスナップショット: 各ステップ実行前後のDOMの状態を視覚的に確認できます。
    • アクションのハイライト: どこをクリックしようとしたかが明確にわかります。
    • 実行時間の確認: コードの実行時間にどれくらい時間がかかったかを確認できます。
    • コンソールログとネットワーク: テスト中のコンソール出力やAPIリクエストの状況をすべて追跡できます。
  • UI Modeを活用する: テスト実行時に --ui オプションをつけることでUI Modeで起動します。trace viewerと同じようなスナップショットや実行時間の確認が実行と同時に行えます。
  • 回線速度の調整: Chromeのデベロッパーツールのネットワークタブで回線速度を「3G回線」などに設定すると、ネットワーク関連の時間がかかる処理を目で追えるようになります。これにより、どこに待機が必要なのか判断がしやすくなります。
  • IDEでの実行時間表示: VS Codeなどのエディタでは、テストコードの横に各行の実行時間がミリ秒単位で表示されます。これにより、意図せず待機が発生している箇所や、逆に待機が機能していない箇所を素早く特定できます。

さいごに

今回は、Playwrightの待機処理について、私たちが普段やっている工夫をまとめてみました!この記事が、皆さんのテストをちょっとでも安定させるヒントになったら嬉しいです。

LayerXのQAチームは、こんな風に技術を楽しみながら、プロダクトの品質に向き合える仲間を募集しています。もし「面白そう!」「ちょっと話を聞いてみたいかも」と思っていただけたら、ぜひ下のリンクからカジュアル面談のエントリーしてください!

jobs.layerx.co.jp