Playwright flakyなテストの安定化

E2Eテストで避けられないのが、フレーキー(flaky)なテストです。コードは同じなのに、実行タイミングや環境によって時々失敗する不安定なテストのことを指します。毎回落ちるテストなら原因を探しやすいですが、フレーキーなテストは再実行すると通ってしまいます。そのため「またフレーキーだな」と失敗原因を深掘りしなくなるリスクにつながるため、なるべく潰しておきたいところです。 この記事では、実際にあったフレーキーな例とその対策や、避けるべきパターンなどをご紹介します。

E2Eテストで避けられないのが、フレーキー(flaky)なテストです。コードは同じなのに、実行タイミングや環境によって時々失敗する不安定なテストのことを指します。毎回落ちるテストなら原因を探しやすいですが、フレーキーなテストは再実行すると通ってしまいます。そのため「またフレーキーだな」と失敗原因を深掘りしなくなるリスクにつながるため、なるべく潰しておきたいところです。

この記事では、実際にあったフレーキーな例とその対策や、避けるべきパターンなどをご紹介します。

① JSで差し込まれる値が空で取得される

ブラウザで表示される値を取得検証するテストにて、取得した値が空文字となってしまいテストが失敗することがありました。

値を取得する部分のコードはこうなっていました。

const total = await page.locator('.total_pay').textContent();

このtotal_payというクラスの要素は、JSで値を差し込む仕組みになっていました。そして、空文字が取得された原因はtextContent()を使っていたことでした。

textContent()は、要素が見つかった時点の文字列をそのまま返します。Playwrightは高速で実行されるため、textContent()が要素を見つけた時点でJSが間に合っていない場合があります。

JSが追いついていなくても要素は存在しているので空の値が取得され、フレーキーなエラーとなっていました。

sequenceDiagram
    participant T as テスト(Playwright)
    participant D as .total_pay 要素
    participant J as ページのJS

    Note over T,J: ❌ 修正前:textContent() をすぐ呼ぶ
    T->>D: textContent()
    D-->>T: ""(要素はあるが値は未挿入)
    J->>D: 値を差し込む(間に合わない)
    Note over T: 空文字で検証 → 失敗

対策として、toHaveText()で「数字が入っている」ことを待つようにしました。こちらは条件を満たすまでリトライしてくれるメソッドです。

const totalLoc = page.locator('.total_pay');
await expect(totalLoc).toHaveText(/[0-9]/);  // 数字が入るまでリトライ

const total = await totalLoc.textContent(); // 数字が入ったあと、安全に期待値を取得
sequenceDiagram
    participant T as テスト(Playwright)
    participant D as .total_pay 要素
    participant J as ページのJS

    Note over T,J: ✅ 修正後:toHaveText() で待ってから取得
    T->>D: toHaveText(/[0-9]/)
    D-->>T: ""(条件不一致 → リトライ)
    J->>D: 値を差し込む
    T->>D: リトライ
    D-->>T: "1,980円"(条件一致)
    T->>D: textContent()
    D-->>T: "1,980円"
    Note over T: 期待値を安全に検証

「取り出す前に、取り出せる状態になったことを確認する」というステップを挟むことでフレーキーを回避しました。

② APIレスポンスのタイミングのズレ

会員の住所変更画面で、郵便番号入力欄に入力すると住所が自動入力される項目があります。自動入力された内容の検証で時々発生するエラーがありました。

郵便番号入力欄は前半の3桁、後半の4桁で2つの入力欄に分かれており、検証手順としては以下のようになっていました。

  1. 編集画面を開く(既存の値として、160-0022が入っている)
  2. 郵便番号を231-0023に編集
  3. 自動反映された住所が期待値(神奈川県 / 横浜市中区 / 山下町)と合っているか照合

このテストコードがこちらです。

// 編集フォームには、前のテストが保存した 160-0022 が入っている
const mail1 = page.locator('#mail1');
const mail2 = page.locator('#mail2');

await mail1.fill('231');
await mail2.fill('0023');

await mail2.blur(); // フォーカスを外す

await expect(page.locator('select[name="prefecture"]')).toHaveValue('神奈川県');
await expect(page.locator('#city')).toHaveValue('横浜市中区');
await expect(page.locator('#address1')).toHaveValue('山下町');   // ← ここだけ落ちる

エラー時は、住所がなぜか「神奈川県 / 横浜市中区 / 横浜公園」と、期待値と微妙に異なっていました。

このフレーキーの原因は、意図しない中間の郵便番号が発生していたことでした。郵便番号の前半に新しい値を入力し、後半の入力欄に移ったときにchangeイベントが発生し、変更途中の郵便番号で住所欄が書き換わっていたようです。

郵便番号 APIが返す住所
元の値 1600022 東京都 新宿区 新宿
前半のみ変更 2310022 神奈川県 横浜市中区 横浜公園
後半も変更 2310023 神奈川県 横浜市中区 山下町

郵便番号を後半まで入力すると、想定しているリクエストの結果で住所が上書きされるので本来は問題ありません。

ですが、実行環境やレスポンスが返ってくるタイミングなどの微妙な差異で、正しい郵便番号の結果が反映されないままアサートが走ってしまったようです。

sequenceDiagram
    participant T as テスト(Playwright)
    participant Z as 郵便番号入力欄
    participant A as 住所API
    participant F as 住所欄

    Note over T,F: ❌ 修正前:160-0022 が入った状態から上書き
    T->>Z: mail1.fill('231')
    T->>Z: mail2.fill('0023')
    Note over Z: mail2に移った時点で change 発生
(この時点の郵便番号は 231-0022) Z->>A: 231-0022 で住所検索 Z->>A: 231-0023 で住所検索 A-->>F: 山下町(231-0023 の結果) A-->>F: 横浜公園(231-0022 の結果)が後から到着 Note over F: 中間値の結果で上書きされる T->>F: address1 が「山下町」か検証 F-->>T: 「横浜公園」→ 失敗

修正方法としては、以下のように入力欄をクリアする処理を入れました。

・・・
await mail1.fill('');   // ← 先に両方を空にして、意図しない状態を作らない
await mail2.fill('');

await mail1.fill('231');
await mail2.fill('0023');
・・・
sequenceDiagram
    participant T as テスト(Playwright)
    participant Z as 郵便番号入力欄
    participant A as 住所API
    participant F as 住所欄

    Note over T,F: ✅ 修正後:先に空にしてから入力
    T->>Z: mail1.fill('') / mail2.fill('')
    T->>Z: mail1.fill('231')
    Note over Z: 後半が空のため郵便番号として不完全
(意図しない中間値は発生しない) T->>Z: mail2.fill('0023') Z->>A: 231-0023 で住所検索 A-->>F: 山下町 T->>F: address1 が「山下町」か検証 F-->>T: 「山下町」→ 成功

中間の状態を作らせないように空欄に戻し、それから入力しなおすようにすることで意図しない中間値によるエラーがなくなりました。

③ 入力マスクが間に合わない

こちらも郵便番号ですが、自動でハイフンつきに整形される入力欄です(JSライブラリMaskaを使用)。9999999と入れると999-9999に整形されるようになっています。

この画面の保存確認のテストで、なぜか「ダッシュ(-)区切りの半角数字で入力してください」というバリデーションエラーでテストが失敗することがありました。JSの整形が効いていないようです。

該当部分は以下のようなコードになっています。

const mailcodeInput = adminPage.locator('input[name="mailcode"]');
const saveButton = adminPage.getByRole('button', { name: '保存', exact: true });

await mailcodeInput.fill('9999999');
await saveButton.click();

ややこしいのですが、Playwrightの失敗時のスクリーンショットではちゃんと999-9999に整形されており、なぜエラーとなったのかがわかりにくい箇所でした。

ここで起こっていたのは、ライブラリの読み込みが時々遅れる → 先にfill()が走る → 整形が効いていないまま保存ボタンクリック → バリデーションエラー、という流れだったようです。

sequenceDiagram
    participant T as テスト(Playwright)
    participant I as mailcode 入力欄
    participant M as Maska(マスクJS)
    participant S as 保存処理(バリデーション)

    Note over T,S: ❌ 修正前:fill() の直後に保存をクリック
    T->>I: fill('9999999')
    Note over M: ライブラリの読み込みが遅れている
    T->>S: 保存ボタンをクリック
    S-->>T: 「9999999」のまま検証 → ハイフンなしでエラー
    M->>I: 999-9999 に整形(間に合わない)
    Note over I: 失敗時のスクリーンショットでは
整形後の値が写っている

そのため、明示的に整形が行われるのを待つようにしました。

・・・・・
await mailcodeInput.fill('9999999');
// マスクの「効果」を待つ
await expect(mailcodeInput).toHaveValue('999-9999');
await saveButton.click();
sequenceDiagram
    participant T as テスト(Playwright)
    participant I as mailcode 入力欄
    participant M as Maska(マスクJS)
    participant S as 保存処理(バリデーション)

    Note over T,S: ✅ 修正後:toHaveValue() で整形を待ってから保存
    T->>I: fill('9999999')
    T->>I: toHaveValue('999-9999')
    I-->>T: "9999999"(条件不一致 → リトライ)
    M->>I: 999-9999 に整形
    T->>I: リトライ
    I-->>T: "999-9999"(条件一致)
    T->>S: 保存ボタンをクリック
    S-->>T: 「999-9999」を検証 → 成功

自動リトライをしてくれるtoHaveValue()を使って、①と同じように状態が整うのを待つようにしました。

④ 別タブが突然閉じられる

メインのタブで操作をしながら別タブを開いて操作を行う、というステップを含むテストにて、タブの往復中にTarget page closedというエラーが発生することがありました。

Playwrightでの別タブは、以下のようにcontext.newPage()で簡単に作ることができます。

test('2つのタブを操作する', async ({ page, context }) => {
  // メインのタブは通常通りの操作
  await page.goto('/');

  // 別タブで開いて、在庫を変更する
  const adminPage = await context.newPage();
  // 別タブにて操作・・・
});

このエラーの原因は特定できていないのですが、個別実行では発生したことがなく、また並列での一括実行でのみ起きるという点から、メモリが関係しているのではと考えています。Chromium系のブラウザには、使われていないタブの内容をメモリから破棄する仕組みがあります。メインタブを操作しているあいだ、使われないままの別タブに何らかの影響を与えていたのかもしれません。

そこで、別タブを操作する前にbringToFront()を呼ぶようにしました。

await adminPage.bringToFront();   // タブを前面に出してから操作する

bringToFront()はタブをアクティブにするためのメソッドです。メモリからの破棄を防ぐものではありませんが、アクティブなタブであれば閉じられにくいのでは、と期待して入れたものです。

現在この箇所は、別タブではなく直接DBを操作する形に変更しているためこの問題が起きる構造ではありませんが、それまでの運用でこのエラーが出ることはありませんでした。原因を特定できていないのでbringToFront()のおかげだったとは言えませんが、フレーキーの中にはこういうタイプのものもあるということで、ご紹介しました。

flakyが起きやすい箇所

ここまで挙げたフレーキーの例を見てみると、多くが「テストは先に進んでいるのに、ページやブラウザ側がまだ追いついていなかった」ことで発生しています。

PlaywrightのAuto-wait(自動待機)が確かめてくれるのは「その要素を操作できるか」まで、Auto-retry(自動リトライ)が待ってくれるのは「指定した条件を満たすか」という部分までで、ページのJSが動作を完了したかまでは見てくれません。

そのため、以下が絡む箇所ではテスト実装時に注意する必要があるようです。

  • JSによる描画
  • APIのレスポンス
  • ライブラリの読み込み

「待つ」メソッドと「待たない」メソッド

自動待機・自動リトライを活用するには、Playwrightの「状態を待ってくれる」メソッドと、「その瞬間の状態だけを扱う」メソッドとを使い分ける必要があります。

名前が似ているものもありややこしいので、よく使うメソッドをまとめました。

種類 メソッド 待つ?
操作 click()fill()check() 自動待機:要素が表示され、操作できる状態になるまで待つ
状態確認 toHaveText()toHaveValue()toBeVisible() 自動リトライ:期待した状態になるまで繰り返し確かめる
値・状態の取得 textContent()inputValue()getAttribute()isChecked() 要素がDOMに現れるまでは待つが、その中身は待たない
表示の判定 isVisible()isHidden() 待たない

この中のisVisible()isHidden()は、以下のようにオプションとして引数に待機時間を渡しても無視されます。待機時間を指定したつもりになって実際には待機していない、ということが起こるので要注意です。

// 5秒待つ、を指定したつもり
await button.isVisible({ timeout: 5000 });

待機を使いたい場合は、素直にexpect(button).toBeVisible()のような自動リトライがあるメソッドを使います。

アンチパターンとその改善

最後に、Playwright非推奨のアンチパターンの中から、私自身も気をつけたいと思っているものをいくつか挙げます。

waitForTimeout()の固定待機

waitForTimeout(500)のような固定の待ちは、ローカルでは0.5秒で足りたけどクラウドでは足りない、のように環境によるフレーキーを起こす原因になります。

待つ場合は固定ではなく、toHaveText()toHaveValue()のような状態を待ってくれるメソッドを使い、ユーザーと同じように画面の描画を元にしてテストを進める形がPlaywright推奨となっています。

同じアイテムの使い回し

「テストはできるだけ互いに独立させる」のが推奨パターンですが、同じアイテムの使い回しは真逆のアンチパターンに当たります。

例えば「商品A」という特定の商品を使って、在庫を増減したり商品を販売停止にしたりのように、商品の状態を書き換えるテストがある場合などです。「商品A」を複数のテストで使い回していると、

  • 前のテストが在庫を0にしたまま終わった
  • 別のテストが販売停止にしている最中だった

などの原因でテストが通ったり落ちたりするフレーキーになります。並列実行もやりにくくなるので、商品や会員など、状態が変わるものは使い回さないほうがいいです。

外部サービスが絡むテスト

こちらではコントロールできない外部サービスは、基本的にはテスト対象外としてモック化が推奨されています。とはいえ、プロジェクトの検証内容と強い繋がりがあり、テストの中で外部サービスを扱う必要があるケースも少なくないと思います。

なので、せめて外部サービスの問題でエラーが発生した場合はエラーを見ればすぐわかるように、専用のエラーメッセージを設定しておくと便利です。

const externalButton = page.getByRole('button', { name: 'クリック' });
await expect(externalButton, '外部サービスのボタンが見つかりません').toBeVisible();

expect()の第2引数は任意のエラーメッセージを設定できるので、デバッグが早くなります。

まとめ

フレーキーなテストは、絶対的な穴でないぶん発見が遅れ、原因を見つけるのにも手間がかかります。

前もってフレーキーになりやすいパターンを知っておくことで、テストを書く段階からある程度フレーキーを避けたテストコードにできたり、テストが失敗しても原因のアタリをつけやすくなるかなと思います。

フレーキーを全部潰そうとすると大変ですが、頻度が高いものだけでもなくしてゆきたいですね。

Hugo で構築されています。
テーマ StackJimmy によって設計されています。