テストが「気まぐれ」で落ちる夜に。Playwrightと心中しないためのCI/CD戦略

テストが「気まぐれ」で落ちる夜に。Playwrightと心中しないためのCI/CD戦略

深夜2時。MacBookのファンが悲鳴を上げ、モニターの青白い光が充血した目に突き刺さる。GitHub Actionsのステータスアイコンが、またしても無慈悲な赤色に染まった。

「さっきまでローカルで通ってたよな?」

そう独り言をこぼし、4杯目のコーヒーを流し込む。いわゆるFlaky tests(不安定なテスト)だ。CI環境で3回に1回落ちるこの「気まぐれ」のせいで、デプロイボタンは今日も押せない。このテスト結果の不安定さは、単なるエラーじゃない。開発者の精神を少しずつ削り取り、プロダクトへの信頼を泥沼に引きずり込む「毒」だ。

今日は、そんな地獄のような夜を終わらせるために、PlaywrightをCIで「信頼できる相棒」に変えるための泥臭い知見を共有する。


1. 「Auto-waiting」という甘い幻想を捨てろ

Playwrightには強力なAuto-waiting機能がある。だが、これを「魔法」だと勘違いしてはいけない。

例えば、モダンなReactアプリで「API通信後にDOMを書き換える」ような処理。PlaywrightはDOMが存在さえすれば、中身が空っぽでも「よし、クリックできるな!」と判断して突き進む。結果、アサーションが空の要素を掴んで爆死する。

失敗の典型:即時判定の罠

// 失敗するパターン:データが描画される前にアサーションが走る
await page.getByRole('button', { name: '送信' }).click();

// 内部で非同期処理が走っている最中に判定が走り、DOMが空で落ちる
await expect(page.locator('.result')).toHaveText('完了'); 
この時、コンソールには `Expected string: "完了", Received: ""` という無機質なログが残る。この「タイミングのズレ」を、コード側でどう制御するかが勝負の分かれ道だ。

2. Web First Assertionこそが「正義」

`locator.textContent()` を `if` 文で判定してデバッグしているそこの君、今すぐやめろ。それはリトライが効かない。

解決策は、Playwrightの `expect` が持つWeb First Assertionだ。これは失敗時にデフォルトで5秒間、内部的にポーリング(再試行)を繰り返してくれる。

// 改善版:Web First Assertionならリトライが効く
await page.getByRole('button', { name: '送信' }).click();

// 内部的にポーリングし続け、最大10秒間「完了」が表示されるのを待つ
await expect(page.locator('.result')).toHaveText('完了', { timeout: 10000 });

なぜこれが最強なのか。それは「DOMの状態が変わるまで待つ」というロジックを、エンジニアが手書きで書かなくて済むからだ。手動の `if` 文や `sleep` は、非同期のイベントループの隙間を埋めるにはあまりに脆弱すぎる。


3. なぜ `waitForTimeout` は「悪」なのか

「とりあえず3秒待てば通るだろ」と `page.waitForTimeout(3000)` を書くのは、エンジニアとしての敗北宣言だ。

1. CIのコスト: テストが遅くなる。並列実行しても、この3秒の積み重ねでCIの待ち時間は数分単位で増える。
2. 本質的ではない: 3秒で足りる保証はない。ネットワークが重い日は5秒必要かもしれない。

代わりに、`page.waitForResponse` を使え。これは「特定のネットワークリクエストが完了する」というイベントを監視する。

// 特定のAPIレスポンスを待つ
const responsePromise = page.waitForResponse(resp => 
  resp.url().includes('/api/v1/data') && resp.status() === 200
);

await page.getByRole('button', { name: '更新' }).click();

// レスポンスが返ってくるまで確実に待機し、即座に次の処理へ移る
await responsePromise; 

await expect(page.locator('.data-table')).toBeVisible();

これを使えば、通信が0.1秒で終われば0.1秒でテストが進む。非同期処理を「祈り」ではなく「確信」に変える手法だ。


4. CI/CDパイプラインを「要塞」にする設定

GitHub Actionsでテストを回すなら、以下の設定は最低限の武装だ。

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - name: Cache node_modules
        uses: actions/cache@v3
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
      - name: Run Playwright
        run: npx playwright test --workers=2 --retries=2
      - name: Upload Artifacts
        if: always() # 失敗しても必ずアーティファクトを保存
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/

  • `actions/cache`: 毎回 `npm install` してたら日が暮れる。キャッシュで時間を買え。
  • `--retries=2`: 分散システム相手に100%の安定を求めるのは傲慢だ。稀に起きるネットワークの瞬断はリトライで吸収しろ。
  • `if: always()`: テストが落ちた時のスクリーンショットとトレースファイルは、犯人特定のための唯一の手がかりだ。これがないと、CIのログを眺めて途方に暮れることになる。

結論:テストは「守り」じゃなく「攻め」だ

Flakyなテストを「まあ、リトライすれば通るし」と放置するのは、故障した警告灯をガムテープで塞いで時速200キロで走るようなものだ。最初はいい。だが、いつか必ず大事故になる。

泥臭いデバッグを深夜に一人で完結させ、GitHub Actionsが緑色に変わった瞬間。あの静かな満足感のために、俺たちはコードを書いているんじゃないのか?

Flakyなテストを憎め。そして、Web First Assertionと適切な通信待機で、CIを「何度でも信頼できる相棒」に叩き直せ。

明日の朝、このコードがデプロイされるのを想像しろ。少しだけ、眠れるはずだ。さあ、ターミナルを開こうぜ。