Playwright 2026年6月17日

「ローカルでは緑なのにCIで落ちる」E2Eテストを安定させる:Playwrightのflaky潰し

XECIN E2Eテストテスト自動化CI/CD技術検証

「ローカルでは全部緑なんですけど、CIだけたまに落ちるんですよね」——E2Eテストを入れた直後の現場で、私が一番よく聞く相談です。

少し前に、受託で引き受けたフロント案件にPlaywrightでE2Eテストを導入しました。ログインして、フォームを入力して、確認画面まで進む、という基本の導線です。ローカルで動かすと毎回パスする。ところがGitHub Actions上では、5回に1回くらいの頻度でどこかが落ちる。再実行すると通る。

この「再実行すると通る」が一番厄介なんです。落ち方に再現性がないと、誰も真剣に向き合わなくなる。そのうち「CIが落ちたらとりあえずリトライ」が習慣になって、テストが赤くても誰も気にしなくなる。こうなるとテストは品質を守る役目を果たさなくなります。

正直なところ、不安定なテスト(flaky test)は「無いテスト」より質が悪いと思っています。落ちても信用されないし、本物のバグが混ざっていても気づけないからです。今回は、このflakyを腰を据えて潰したときの切り分けと対処を、実測値と一緒に残しておきます。


まず「測れないものは改善できない」ので、flaky率を出す

向き合うと決めたとき、最初にやったのは原因究明ではなく計測でした。感覚で「たまに落ちる」と言っていても改善したかどうか判断できないので、まず現状の数字を出します。

CIで同じテストスイートを連続で回して、落ちた回数を数えました。

時点20回実行で落ちた回数flaky率
対策前3回約15%
対策後0〜1回約1%以下

先に結果を書いてしまいましたが、15%というのは「1日に何度もCIを回すと、ほぼ毎日どこかで赤が出る」水準です。これを1%以下まで落とすのが今回のゴールでした。

落ちたケースのスクリーンショットとログをひとつずつ見ていくと、原因は大きく4種類に分類できました。順番に書いていきます。


原因1: 固定の待機(sleep)に頼っている

一番多かったのがこれでした。画面遷移やAPIの応答を待つために、決め打ちの待機時間を入れているパターンです。

最初に書かれていたコードは、こんな感じでした。

// 対策前: 1秒待ってから次へ進む、という決め打ち
await page.click('button.submit');
await page.waitForTimeout(1000); // APIが返るのを「だいたい1秒」で待つ
const message = await page.textContent('.result');
expect(message).toBe('送信が完了しました');

ローカルだと1秒でAPIが返るので通ります。でもCIのランナーは非力で、ネットワークも遅い。1秒では確認画面の描画が間に合わず、まだ空の .result を読んでしまって落ちる。

恥ずかしい話、最初の私はここで待機時間を2秒、3秒と増やしてしまいました。確かに落ちる頻度は下がるんですが、今度はスイート全体の実行時間が伸びる。しかも遅いだけで、根本的には「運が悪いと間に合わない」状態のままなんですよね。テストは速くて確実であってこそ意味があるのに、遅くて不確実という最悪の方向に進んでいました。

Playwrightのドキュメントには「waitForTimeout を本番のテストで使うな」とはっきり書いてあります。代わりに、Playwrightの locator とアサーションが持っている自動待機(auto-wait)に任せるのが正解でした。

// 対策後: 要素が「期待する状態になるまで」自動で待つ
await page.getByRole('button', { name: '送信' }).click();
await expect(page.getByText('送信が完了しました')).toBeVisible();

expect(locator).toBeVisible() のようなweb-firstアサーションは、その要素が条件を満たすまで内部で繰り返しチェックして待ってくれます。デフォルトのタイムアウトに収まれば即座に次へ進むので、速い環境では速く、遅い環境では必要なだけ待つ。固定のsleepを全部このスタイルに置き換えただけで、落ちる回数が目に見えて減りました。


原因2: テストが互いの状態に依存している

次に多かったのが、テスト同士が暗黙のうちに状態を共有しているケースです。

たとえば「商品をカートに追加するテスト」と「カートを空にするテスト」が、同じユーザーアカウント・同じカートを使い回していました。ローカルで1スレッドで順番に流すと問題なく通ります。ところがCIで並列実行に切り替えた瞬間、片方がカートを空にしている最中にもう片方が中身を確認して落ちる、という事故が起きました。

この手のflakyは「順番に動かすと通るのに、並列にすると落ちる」という出方をするのが特徴です。

考え方としては、各テストは自分の前提を自分で用意して、他のテストの実行順や結果に依存しないこと。Playwrightはテストごとにブラウザコンテキスト(cookieやストレージが分離された独立環境)を新しく作ってくれるので、ブラウザ状態は元々分離されています。問題は、その先のサーバ側データでした。

そこで、テストごとに使うデータを分けるためにfixtureを用意しました。

import { test as base } from '@playwright/test';

// テストごとに専用のテストユーザーを払い出すfixture
export const test = base.extend<{ account: { email: string } }>({
  account: async ({}, use) => {
    const unique = `e2e+${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
    const created = await createTestAccount(unique); // 専用APIで使い捨てユーザーを作る
    await use(created);
    await deleteTestAccount(created.email); // 後始末まで責任を持つ
  },
});

こうしておくと、各テストは自分専用のユーザーで動くので、別のテストがそのデータをどういじろうと影響を受けません。「動いた=品質OK」ではなくて、「他のテストと同時に動かしても安定して動く」ところまで確認して、ようやく信頼できるテストになります。


原因3: セレクタが脆い

これは件数こそ少なかったものの、一度ハマると直すのに時間がかかるタイプでした。

落ちていたテストのセレクタを見ると、こういうものが混ざっていました。

// 脆い例: DOM構造やクラス名に強く依存している
await page.click('div.container > form > div:nth-child(3) > button.btn-primary');

このセレクタは、デザイン調整でラッパーの div が1枚増えたり、CSSのクラス名が変わったりしただけで壊れます。実際、別のメンバーがマークアップを少し直したPRで、このテストだけがいきなり落ちはじめました。テスト対象の機能は何も壊れていないのに、です。

Playwrightが推奨しているのは、ユーザーから見える意味(役割やラベル)を基準にしたロケータです。

// 安定する例: 利用者から見た「意味」で要素を指す
await page.getByRole('button', { name: '送信' }).click();

// 意味で取りにくい要素には、テスト専用のtest-idを振る
await page.getByTestId('order-total').click();

getByRolegetByLabel は、見た目のDOM構造ではなくアクセシビリティ上の役割で要素を探すので、レイアウトを変えても壊れにくい。どうしても役割で特定できない要素には、data-testid を明示的に振っておく。少し手間ですが、「マークアップ変更でテストが落ちる」を構造的に減らせます。

ここは好みが分かれるところですが、私は「テストコードはアプリの実装詳細ではなく、利用者の体験に紐づける」という原則で揃えるようにしています。実装が変わるたびにテストを直すようでは、テストが足かせになってしまうので。


原因4: CIの並列実行とリトライの設定が現場と合っていない

最後は、テストそのものではなく実行環境側の設定です。

ローカルは性能に余裕があるので、たくさんのワーカーで並列に流しても平気です。一方CIのランナーはCPUもメモリも限られていて、欲張って並列度を上げると、リソースの取り合いでタイムアウト気味になり、かえってflakyが増えます。私も一度ワーカー数を上げて速くしようとして、逆に落ちる頻度が上がって戻したことがあります。

結局、設定を環境で分けるのが現実的でした。

// playwright.config.ts(抜粋・改善の余地あり)
export default defineConfig({
  // CIではまず安定優先。落ち着いたら少しずつ workers を増やす想定。
  workers: process.env.CI ? 1 : undefined,
  // CIでだけ最大2回までリトライ。flakyを「検知」しつつ赤を減らす
  retries: process.env.CI ? 2 : 0,
  use: {
    // 失敗時にだけトレースを残す。原因究明はこれが効く
    trace: 'retain-on-failure',
  },
});

workers: 1 は最初の安定化フェーズの設定で、これは改善の余地があります。本来は速度のために並列にしたいので、スイートが安定したらワーカー数を2、3と慎重に上げていく前提です。「最初から速く」ではなく「まず安定、それから速く」の順番が大事だと考えています。

retries は使い方を間違えると危険です。本物のバグまでリトライで握りつぶしてしまうと意味がない。なので私は、リトライが発生したテストはCIのレポートに「flakyとして検知された」と記録が残るようにして、後から「なぜ1回目で落ちたのか」を必ず棚卸しするようにしています。リトライは赤を消す道具ではなく、不安定なテストを見つけるための仕組みとして使う、という線引きです。

そして地味に一番効いたのが trace: 'retain-on-failure' でした。失敗したときだけ操作のトレース(各ステップのスクショ・DOM・ネットワーク)が残るので、Trace Viewerで開けば「CIでは落ちるがローカルでは再現しない」という一番つらいケースでも、どのステップで何が起きていたかを目で追えます。原因2のカート競合も、これでようやく「別テストが同時にデータを消していた」と特定できました。


受け入れ基準として線引きしておく

切り分けが終わったところで、チームで「どこまでいけばE2Eテストを信頼してよいか」を決めておきました。基準が曖昧だと、また「とりあえずリトライ」に戻ってしまうからです。

観点受け入れ基準
安定性CIで20回連続実行し、flaky率1%以下(リトライ込みで赤が出ない)
待機waitForTimeout をテストコードから原則排除する
独立性テストの実行順を入れ替えても、並列にしても結果が変わらない
セレクタrole / label / test-id ベース。nth-child や深いCSSパスを使わない

こうやって基準を文章にしておくと、レビューのときに「このテスト、sleep入ってますね」と具体的に指摘できる。テストは目的ではなく手段なので、手段が信頼できる状態を基準で担保しておくのが、結局は近道だと思います。


振り返って

やったことを並べてみると、「固定sleepを自動待機に置き換える」「テストごとにデータを分ける」「セレクタを意味ベースにする」「CIの並列とリトライを環境で分ける」という、どれも派手さのない作業の積み重ねでした。新しいツールを入れたわけでもありません。

それでもflaky率が15%から1%以下まで下がって、CIの赤を見たときに「本物のバグだ」と全員が反応するようになったのは大きかったです。テストが信用されている状態というのは、品質を語るうえで思っていた以上に効いてきます。

今思えば、最初に待機時間を伸ばして逃げようとしたのは典型的な遠回りでした。flakyは「待てば直る」問題ではなく、たいてい設計の問題なんですよね。

今後は、新しくE2Eを書く段階からこの4つの観点をレビュー項目に入れて、不安定なテストが増えてから慌てて潰すのではなく、最初から安定したテストだけが積み上がっていく形にしていきたいと考えています。もっと良い安定化のやり方があれば、ぜひ知りたいところです。