テストが「気まぐれ」で落ちる夜に。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を「何度でも信頼できる相棒」に叩き直せ。
明日の朝、このコードがデプロイされるのを想像しろ。少しだけ、眠れるはずだ。さあ、ターミナルを開こうぜ。