「テスト成功!」の5分後に本番DBを飛ばした話。Playwrightで築く「死なない」CI/CD戦略

「テスト成功!」の5分後に本番DBを飛ばした話。Playwrightで築く「死なない」CI/CD戦略

「テストが通った!」と喜んでデプロイボタンを押した5分後、本番環境のDBが吹き飛んだ。あの時の絶望感と、深夜のオフィスで一人泣きそうになりながらロールバックした記憶は、一生忘れられないトラウマだ。E2Eテストを「単なる動作確認」と甘く見ていた当時の自分を、全力で殴りに行きたい。

個人開発でPlaywrightを導入した当初、僕は「とりあえず動けば正義」という脳筋スタイルでテストを書いていた。GitHub Actionsに載せれば自動化完了!なんて思っていたのが運の尽き。実際は「Flaky Test(不安定なテスト)」という名の悪魔に、開発時間の半分を食いつぶされることになったんだ。

この記事は、泥臭い失敗を繰り返した僕が、ようやく辿り着いた「エンジニアの精神衛生を守るためのE2Eテスト戦略」の記録だ。


1. なぜE2Eテストは「ゴミ」になりやすいのか

一番の失敗は、テストの粒度がデカすぎたこと。ログインから決済完了までを一つの関数に詰め込み、さらに画面遷移の待ち時間を `page.waitForTimeout(3000)` でハードコーディングしていた。

これが地獄の始まりだ。CI環境のGitHub Actionsはスペックが貧弱なことが多い。たまに処理が遅延すればテストは落ちる。でもローカルでは成功する。「再現性のないエラー」に何時間溶かしたことか。

Flaky Testを撲滅する設定

`waitForTimeout`は即刻捨てろ。Playwrightの「Auto-waiting」を信じるのが正解だ。また、CI環境でのリトライ戦略と並列実行のバランスが鍵になる。

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true, // テストの並列実行を有効化
  
  // CI環境で「test.only」が残っていると事故の元。コミット前に検知する
  forbidOnly: !!process.env.CI,
  
  // Flakyなテスト対策。CIのみリトライを許可する
  retries: process.env.CI ? 2 : 0,
  
  // CIでworkersを絞る理由:メモリ不足回避だけでなく、
  // 共有DBに対する同時接続によるデッドロックを防ぐためでもある。
  // リソースと競合のバランスを見て調整せよ。
  workers: process.env.CI ? 1 : undefined,
  
  use: {
    baseURL: 'http://localhost:3000',
    // 成功時は不要なリソースを食わない。落ちた時だけトレースを残すのが定石
    trace: 'on-first-retry',
  },
});


2. GitHub Actions:キャッシュ戦略こそが正義

CIが遅いとテストを書くのが億劫になる。`npm install` とブラウザバイナリのダウンロードを毎回走らせていたら、それだけで数分溶ける。以下の構成が、今の僕の最適解だ。

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Cache Playwright Browsers
        uses: actions/cache@v4
        with:
          path: ~/.cache/ms-playwright
          key: ${{ runner.os }}-playwright-${{ hashFiles('**/package-lock.json') }}
          
      - name: Install dependencies
        run: npm ci
      - name: Install Playwright Browsers
        run: npx playwright install --with-deps
      # ...以下テスト実行ステップ

`actions/cache` でブラウザバイナリをキャッシュすることで、インストール時間を劇的に短縮できる。ここをサボると、CIの待ち時間がモチベーションを削る最大の要因になる。


3. 「テストデータの汚染」を防ぐ唯一の解

テスト同士がDBを共有すると、あるテストが作成したデータが別のテストを邪魔する。「全テストが赤色に染まる」あの絶望的な朝を迎えないためには、テストごとにデータをクリーンに保つ必要がある。

僕は `storageState` と `beforeEach` を組み合わせて、認証済み状態を使い回しつつ、データは毎回リセットする設計にしている。

// tests/fixtures.ts
import { test as base } from '@playwright/test';
import { resetDb } from './utils/db'; // 独自実装のDBリセット関数

export const test = base.extend({
  page: async ({ page }, use) => {
    await resetDb(); // 各テストの前にクリーンな状態を作る
    await use(page);
  },
});

`data-testid` をHTMLに埋め込むのも鉄則だ。`text=送信` みたいなセレクタは、デザイナーが文言を「確定」に変えただけでテストが死ぬ。UIの変更に耐えうる「頑健なテスト」を書くためには、HTML構造に依存しないID管理が不可欠だ。


4. 現在の僕:トラウマをどう克服したか

今、僕はあの日のような恐怖を感じることはない。なぜなら、「テストが落ちることは、システムが守られたことを意味する」と心から理解できたからだ。

以前は「テストを通過させること」が目的になっていた。今は違う。「壊すべき箇所を検知し、デバッグの時間を最小化する仕組み」を作ることが目的だ。`trace: 'on-first-retry'` で生成されるレポートを見れば、どこで何が起きたか一目瞭然。深夜のオフィスでログを漁る必要はもうない。

E2Eテストは、書いたその日から負債へのカウントダウンが始まる。だが、適切に管理されたテストは、最強のデプロイのお守りになる。

信じるな、疑え。そして、エラーログを愛せ。
もしあなたが今、テストの不安定さに泣いているなら、まずは `retries` と `cache` から見直してほしい。そこからすべてが変わるはずだ。