「テスト成功!」の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` から見直してほしい。そこからすべてが変わるはずだ。