E2Eテストに殺されかけた夜、僕が「Flaky Test」を撲滅するために捨てたもの
CI環境でテストが落ちるたびに「またお前か!」と叫び、深夜3時に修正コミットをプッシュして翌朝絶望する――そんな地獄のような日々を過ごしていた。
Playwrightを導入した当初は、これでE2Eテストの悪夢から解放されると確信していた。しかし現実は非情だ。ローカルでは100%通るのに、GitHub Actionsに乗せた途端、確率で落ちる「Flaky Test(不安定なテスト)」の山。なぜ、僕のコードはCI環境でだけ裏切るのか。これは、E2Eテストとの泥沼の戦いから得た、血の滲むような教訓の記録だ。
1. 「待てばいい」という甘えが、すべてを破壊していた
テストが落ちるたびに、僕は思考停止して`waitForTimeout(3000)`をぶち込んでいた。これが最悪の判断だった。
コードは汚れるし、実行時間は無意味に伸びる。何より、3秒待ってもネットワークが詰まれば普通に落ちる。「とりあえず待てば解決するだろう」という甘えが、テストを不安定にしていた元凶だった。
なぜ、PlaywrightのAuto-waitingを信じられなかったのか?
当時の僕は、`click()`や`fill()`が裏で何をしているのかを理解していなかった。Playwrightは「要素が表示され、インタラクション可能になるまで」勝手に待ってくれる。余計な明示的待機は、この賢い仕組みの邪魔をするノイズでしかなかった。
`waitForTimeout`をすべて削除し、テストが赤くなるたびに「なぜ今、この要素が見えないのか?」と向き合う。泥臭いけれど、この作業こそが安定したテストへの唯一の近道だった。
2. GitHub Actionsで「並列実行」という罠にハマる
CIの実行時間を短縮しようと、`fullyParallel: true`にしてMatrix戦略で分割実行(shard)を試みた時、僕のテスト環境は崩壊した。メモリ不足でブラウザが次々とクラッシュし、結果は「全滅」。
ここで学んだのは、「並列数はサーバーのスペックと相談しなければならない」ということ。そして、shardを使う際は「テスト間に依存関係を作ってはいけない」という鉄則だ。Aテストの結果をBテストが引き継ぐような設計にしていると、分割した瞬間にテストは崩壊する。
現実的な設定値として、僕は以下のバランスに落ち着いた。
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, // 失敗した時だけリトライ
workers: process.env.CI ? '50%' : undefined, // 全CPUを使うとメモリが死ぬので半分に
use: {
browserName: 'chromium',
trace: 'on-first-retry', // これが神機能。失敗時のみトレースを残す
},
});
特に`trace: 'on-first-retry'`は革命的だった。失敗した時だけ詳細なログとスクリーンショットが残るため、ストレージを圧迫せずに「なぜ死んだか」を追える。
3. なぜ「Web-Firstアサーション」が最強なのか
Flaky Testの正体は、だいたいが「APIのレスポンス待ち」か「アニメーションの遅延」だ。ここで、手動で状態を確認するコードを書くのはやめよう。
// 【アンチパターン】一瞬の状態を切り取って判定するため、Flakyになりやすい
await page.click('#submit-button');
const successMsg = await page.textContent('.message');
expect(successMsg).toBe('成功しました');
// 【推奨】Web-Firstアサーション。要素が出るまで最大5秒間リトライしてくれる
await expect(page.locator('.message')).toHaveText('成功しました');
この`toHaveText`のようなアサーションは、内部で勝手にリトライを繰り返す。ネットワークの微細な揺らぎによる「一瞬の表示遅延」を、コードを汚すことなく吸収してくれる。これに気づいた時、ようやくテストが「生き物」として安定し始めた。
4. 外部APIという「外部要因」を遮断する
それでも消えないFlaky Testがあった。原因はバックエンドのAPIだ。CI環境で外部APIを叩くと、たまに500エラーが返ってきたり、レスポンスが遅延したりする。これではテストではなく「運試し」になってしまう。
僕は決断した。「外部APIをテスト環境で叩くのはやめよう」。
Playwrightの`route`機能を使って、APIレスポンスを固定(モック)した途端、テストの成功率は99%から100%に変わった。
// テストの冒頭でAPIをモック化する
await page.route('**/api/user', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ name: 'Test User' }),
}));
トレースログを見ると、以前は「API応答待ちのタイムアウト」で死んでいたテストが、モック化によってスパッと通るようになった。テストすべきは「APIの正しさ」ではなく「UIがAPIの結果をどう処理するか」だ。ここを切り分ける勇気が、CIの安定を呼ぶ。
最後に:テストを信じるな、仕組みを信じろ
E2Eテストは、書いた瞬間から腐敗が始まる。メンテナンスは確かに苦痛だ。でも、そのコストを払ってでも「デプロイボタンを押す瞬間のあの心臓がキュッとなる恐怖」から解放されるなら、安いものだ。
僕が学んだのは、「テストを信じるな、仕組みを信じろ」ということ。
泥臭い待機処理をコードに埋め込むのではなく、Playwrightの設計思想に身を委ねる。そして、失敗した時に原因がすぐ分かる「しぶとい仕組み」を作る。もし今、CIで頭を抱えている仲間がいるなら、まずは`waitForTimeout`を全部消すことから始めてみてほしい。
そこからが、本当のE2Eテストの始まりだ。