こんにちは、賃貸募集支援事業プロダクト開発チームの張(チョウ)です。普段は賃貸物件への申込みをWebで完結させるプロダクトの開発をしています。私たちのチームは昨年末からPlaywrightを導入し、E2Eテストの運用を開始しましたが、テスト数が増加するにつれて、どうしても避けて通れない「Flaky Test(不安定なテスト)」の問題に直面しました。
Flakyテストとは
Google Testing Blog (2016) によると、Flaky Testは「全く同じコードであるにもかかわらず、成功と失敗の両方の結果を示すテスト」と定義されています。この不安定さの背景には、並行処理による競合、実行のたびに結果が変わる非決定的な挙動(Non-deterministic)、あるいはネットワークやサーバーといったインフラ環境の不安定さなど、様々な要因が挙げられます。詳しくは、Google Testing Blog の記事「Flaky Tests at Google and How We Mitigate Them」も参考になります。
Flaky Testを放置すると、本来修正すべき致命的なバグ(正当な失敗)を見逃すリスクが高まります。さらに、失敗したテストをパスさせるためにCI上で何度も再実行(Retry)を繰り返すことになり、デプロイサイクルの停滞や開発効率の低下を招く可能性があります。
こうした理由から、テストを書く段階から不安定さを排除する実装を意識することが重要になります。
今回は、私たちがFlaky Testと苦戦しながら学んだノウハウを共有します。紹介する内容はすべてPlaywrightの公式ドキュメントに基づいたものです。この記事が、皆さんのテストコードを少しでも安定させ、Flaky Testの悩みを減らすためのヒントになれば幸いです。
要素の唯一性の担保と厳密なロケーター取得
テストが不安定になる大きな原因の一つは、意図した要素を正確に特定できていないことにあります。Playwrightでは、DOM構造に依存するのではなく、ユーザーの視点に立ったロケーターの指定が推奨されています。
例えば、リストの要素をnth(1)のようにインデックスで指定するのは避けるべきです。データの読み込み順序やAPIのレスポンス速度によって、画面上の並び順やDOM構造が実行のたびに変動し、nth(1)が指す対象が変わってしまう可能性があるからです。
代わりに、PlaywrightはgetByRoleやgetByTextのようなユーザー向けの属性(User-facing attributes)に基づいたロケーターの活用を推奨しています。これに加えて、nameやexactオプションを組み合わせることで、要素の唯一性を担保します。詳しくは公式ドキュメントの「Playwright Best Practices」も参照してください。
// 不安定な例:構造や順番に依存している await page.locator('button').nth(1).click(); // 安定した例:ユーザーが見ている情報で特定する await page.getByRole('button', { name: '送信', exact: true }).click();
Web-first AssertionsとAuto-waitingの活用
waitForTimeoutを使った固定時間の待機は原則として避けるべきです。公式サイトにも明記されている通り、時間を待つテストは本質的に不安定になりやすく、固定時間の待機を使うと、待機時間が不必要に長くなって実行速度が落ちたり、実行環境の負荷によって要素の出現が間に合わなかったりする原因になるためです。
Playwrightでは、expect(page.getByText('...')).toBeVisible()やtoBeHidden()といったWeb-first Assertionsの使用を推奨しています。これらのアサーションは、単に要素がDOMに存在するだけでなく、要素が安定し、実際にユーザーが操作可能な状態(Actionability)になるまで自動的にリトライしてくれます。
また、画面遷移をより確実に行いたい場合や、特定のURLに到達したことを明示的に担保したい場合は、expect(page).toHaveURL()の使用が推奨されます。これにより、ページ遷移の完了を確認した上で次のテストへ進むことができます。
さらに、ボタンクリック後の処理をより確実にテストしたい場合は、page.waitForResponse()を使ってAPIのレスポンスやステータスコードを確認することをお勧めします。正しいレスポンスを受け取ったことを確認してから次のステップに進むことで、ネットワーク遅延にも強い堅牢なコードになります。
ただし、クリック操作によってAPIレスポンスを待機する場合、クリック後に待機を開始すると処理のレースコンディションが発生する可能性があるため、クリック操作の前にリスナーを定義しておくか、あるいはPromise.allを使ってアクションと待機を同時に実行する必要があります。
参考:
// 不安定な例:固定時間の待機 await page.waitForTimeout(3_000); // 安定した例:表示されるまで自動で待機 await expect(page.getByText('保存しました', { exact: true })).toBeVisible();
// 応答を待機するPromiseを先に定義する const sendApiResponse = page.waitForResponse( (response) => response.url().includes("api/sample") && response.status() === 200, ); // トリガーとなる操作を実行する await page.getByRole("button", { name: "送信" }).click(); // レスポンスが返ってくるのを確定させてから次へ進む await sendApiResponse; // 画面遷移が完了したことを確定させてから次へ進む await expect(page).toHaveURL(/sample\/url/);
// Promise.allを使用した場合 await Promise.all([ page.waitForResponse(res => res.url().includes("api/sample") && res.status() === 200), page.getByRole("button", { name: "送信" }).click(), ]);
VRTにおけるFlakyの回避
VRT(Visual Regression Testing)テストは、1ピクセルの差でも失敗と判定されるため、E2Eテストの中でも特にFlakyになりやすい領域です。テストを安定させるには、スナップショットを撮る前に画面上の「動的要素」や「非決定的な状態」をなるべく排除し、実行環境を完全に同一の状態に整える必要があります。
例えば、画面サイズの不一致や、意図せず残ったホバー状態、再生中のアニメーションなどは、スナップショットを撮るタイミングによってキャプチャ結果をバラつかせる大きな要因となります。これらを防ぐために、撮影前にViewportサイズを固定し、.blur() を活用してフォーカスを外すなど、状態の制御が重要になります。また、スクロールが必要な箇所については、ターゲットとなる要素が完全に表示領域内に配置されているかを事前に確認すると良いです。
撮影対象に日付や時刻のような動的コンテンツが含まれた場合、Playwrightでは、「マスク(Mask)」の活用や「時刻の固定(Mock)」が推奨されています。maskオプションで特定の要素を比較対象から除外したり、page.clock.setFixedTime() を使ってアプリケーション内のシステム時刻を特定の日時に固定したりすることで、実行のたびに変化してしまう要素を制御し、テストが不安定になるリスクを最小限に抑えることができます。
- スクリーンショット: page.screenshot
- フォーカスを外す: locator.blur
- スクロール: Scrolling
- マスク付きスクリーンショット: page.toHaveScreenshot(..., { mask })
- 時刻の固定: clock.setFixedTime
// フォーム操作と状態のクリーンアップ const input = page.locator('input[name="text"]'); await input.fill('テスト入力'); // フォーカスを外してキャレットを消す await input.blur(); // Viewportサイズを固定する await page.setViewportSize({ width: 1280, height: 3000 }); // 時刻を特定の日時に固定する page.clock.setFixedTime(new Date("2026-01-01T10:00:00")); // 動的要素のマスキング await expect(page).toHaveScreenshot("sample.png", { mask: [page.getByTestId('time-display')], });
テストの独立性の確保と実行順序の制御
E2Eテストが前後のテストの状態に依存すると、並列実行時に予期しない失敗を招くため、Playwrightのテストは原則各テストケースが互いに影響せず、他から独立している状態(Shared-nothing architecture)であるべきです。公式ドキュメントにも「シリアル実行は推奨されず、テストを独立して実行できるように分離させるほうが望ましい」と明記されていて、個々のテストが独立して実行できるよう分離することが推奨されています。
この独立性を担保するために、test.beforeEachを活用し、各テストの開始直前に必要なデータのセットアップやページの状態の初期化を行う手法が推奨されます。
しかし、システムの制約上どうしても依存関係が避けられない場合には、test.describe.configure({ mode: 'serial' })によるシリアルモードを利用して実行順序を強制的に指定することができます。ただし、シリアルモードには「実行中のテストが一つでも失敗すると、それ以降のテストはすべてスキップされる」「失敗した場合、リトライ時はそのグループの最初のテストから再試行される」という制約があるため、テスト実行時間の延長やフィードバックの遅延を招きやすいため、使用には注意が必要です。
参考:
- test.describe.configure のシリアルモード
- test.beforeEach
// 必要なデータのセットアップやページ状態の初期化を行う test.beforeEach(async ({ page }) => { const samplePage = new SamplePage(page) await samplePage.goToTestPage(testID); // ヘルプボタンは常に非表示にする await hideHelpButton(page); })
// 新規申込作成の一連テスト test.describe("entryシナリオテスト", () => { test.describe.configure({ mode: "serial" }); test("新規申込を作成できる", async ({ page }) => { // 新規申込作成シナリオのテスト内容 }); test("TODOを作成できる", async ({ page }) => { // TODO作成シナリオのテスト内容 }); test("申込詳細を編集できる", async ({ page }) => { // 申込詳細編集シナリオのテスト内容 }); })
効率的なデバッグとリトライの運用
最後に、Flakyテストへの対応で役立つデバッグツールとリトライの運用について紹介します。
どれほど堅牢なコードを書いても、ネットワークの不安定さやブラウザの挙動により、E2EテストでFlakyテストを完全に排除することは難しいです。そのため、「コーディング段階での予防」と「失敗時の迅速な原因特定」の切り分けが重要になります。
まず予防策として、PlaywrightではTypeScriptとESLint(eslint-plugin-playwright)によるリンティングを推奨しています。これにより、waitForTimeoutの安易な利用や、非同期呼び出しにおけるawaitの欠落など、Flakyテストの原因となる基本的なミスをコーディング段階で未然に防ぐことができます。
その上で、万が一失敗が発生した際には、Playwrightが推奨しているTrace Viewerを使用すれば、テストが失敗した瞬間のスナップショット、ネットワークリクエスト、コンソールログなどを詳細に確認できます。開発環境で失敗時の状態を正確に再現できるため、Flakyテストになった根本原因が特定しやすくなります。
また、一時的なインフラの不安定さへの対策として、retries設定を有効にして、自動リトライを行うことも一つの手段です。リトライを導入することで、CIのパス率は向上させることができます。
ただし、多くの技術記事でも指摘されている通り、リトライはFlakyテストそのものの根本的な修正に代わるものではありません。Playwrightではretries設定を有効にすると、失敗したテストを複数回再試行してくれますが、これに過度に依存して放置すると、本来修正すべきバグが隠蔽されてしまう恐れがあります。Flakyによって失敗したテストは速やかに原因を特定・修正し、常に安定してパスする状態を維持し続ける必要があります。
これらの詳細や関連情報については、以下のPlaywright公式ドキュメントも参考になります。 - Playwright Best Practices - Lint your tests - Trace viewer - Test retries
// eslint.config.js import tsEslintParser from "@typescript-eslint/parser"; import playwright from "eslint-plugin-playwright"; export default [ { files: ['tests/**/*.ts', 'tests/**/*.js'], languageOptions: { parser: tsEslintParser, parserOptions: { project: "./tsconfig.json", }, }, plugins: { playwright }, rules: { ...playwright.configs.recommended.rules, }, }, ]
# テストが失敗した原因を特定するために使う npx playwright test --trace on # CIで失敗したトレースをローカルで解析する場合 npx playwright show-trace path/to/trace.zip
// playwright.config.tsの設定で、CI環境の場合は2回までリトライを許可する retries: process.env.CI ? 2 : 0,
おわりに
今回は、私たちが実際の開発現場で試行錯誤しながら学んだFlaky Testへの対策について紹介しました。
Playwrightは進化が非常に速いですが、公式ドキュメントには常にベストプラクティスや最新情報がアップデートされています。迷ったときはすぐに公式ドキュメントを確認し、一次情報に立ち返ることが一番の近道だと感じております。
最後までお読みいただき、ありがとうございました。