Playwrightで「テストの死体」を量産しないための、泥臭い生存戦略
深夜2時。画面に並ぶ真っ赤なGitHub Actionsのログ。
「またか」。思わず椅子を蹴りそうになる。ローカルではグリーンなのに、CI上では平然と落ちる。いわゆる「Flaky Test(不安定なテスト)」という名の亡霊だ。
Playwrightを導入して半年。最初は「これで自動化は完璧だ」と高揚していたが、現実は甘くなかった。CIに統合した瞬間、テストは品質を守る盾ではなく、開発者の精神を削る「アラート爆弾」へと変貌した。
この記事は、AIが書くようなきれいごとのTips集ではない。僕が幾度もの深夜のデバッグと、冷めきったコーヒーの苦味から学んだ「泥臭い運用術」の全記録だ。
1. 並列実行の「限界点」を物理で殴りに行く
GitHub Actionsのデフォルト設定で「とりあえず動いた」と安心してはいけない。テストがランダムに落ちる原因の多くは、単なるスペック不足だ。
GitHubのホステッドランナー(`ubuntu-latest`)は、実質的に2コアのCPUだ。ここにPlaywrightの並列実行(`--workers`)をフルスロットルで投げ込めば、ブラウザの起動すらままならず、タイムアウトの嵐が吹き荒れるのは物理的に必然だ。
僕は `--workers=50%` という設定に辿り着いた。これは単なる感覚値ではない。「コア数の半分をテストに割り当て、残り半分をブラウザの描画とOSのオーバーヘッドに譲る」という、物理的な限界点だ。
# .github/workflows/playwright.yml
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Playwright tests
# 2コア環境で1コア分を割り当てることで、プロセス競合によるタイムアウトを抑制
run: npx playwright test --workers=50%
env:
CI: true
もしこれでも落ちるなら、それはマシンのせいではなく、テストコードが重すぎるサインだ。
2. 「待ち」の哲学:ハードコーディングは悪魔の所業
初心者が真っ先に書く `page.waitForTimeout(3000)`。これはテストを不安定にするための呪文だ。3秒待っても描画が終わらなければ落ちるし、1秒で終わるなら2秒の無駄。
PlaywrightのAuto-waitingは強力だが、裏側のAPI通信までは感知できない。僕が辿り着いたのは、「状態の確定」をトリガーにするという鉄則だ。
// 悪い例:運任せの待機
await page.waitForTimeout(2000);
await page.click('#submit-btn');
// 良い例:DOMの「状態」をトリガーにする
// API通信完了後に付与されるクラスや、ボタンの活性化を待つ
await page.locator('.is-loaded').waitFor({ state: 'visible' });
await page.click('[data-testid="submit-btn"]');
3. リトライは「治療」ではなく「鎮痛剤」だ
`playwright.config.ts` で `retries: 2` と設定すれば、確かにCIの赤色は消える。だが、それは問題を隠蔽しているに過ぎない。
僕のチームでは、「リトライでパスしたテスト」は負債としてバックログに積む運用にしている。
// playwright.config.ts
export default defineConfig({
// CI環境のみリトライを許可。ただし「なぜ落ちたか」のログは必ず残す
retries: process.env.CI ? 2 : 0,
reporter: [['list'], ['html', { open: 'never' }]],
});
リトライが発生したら、Slackの専用チャンネルに通知を飛ばす。「リトライで誤魔化せた」と喜ぶな。 それは、いつか必ず爆発する時限爆弾なのだ。
4. 保守性の要:Page Object Model (POM) の導入
UIの変更で全テストが死ぬのは、セレクタをテストコードに直書きしているからだ。デザイン変更のたびにテストを修正するなんて、エンジニアの仕事じゃない。
セレクタを抽象化し、ビジネスロジックを分離する「Page Object Model」を導入せよ。
// LoginPage.ts
export class LoginPage {
readonly page: Page;
readonly submitButton: Locator;
constructor(page: Page) {
this.page = page;
this.submitButton = page.locator('[data-testid="login-submit"]');
}
async login(user: string, pass: string) {
await this.page.fill('[name="user"]', user);
await this.submitButton.click();
}
}
これなら、ボタンの属性が変わっても修正箇所は1箇所で済む。テストコードを「壊れにくい資産」に変えるための、最低限の投資だ。
5. 結論:E2Eは「育てる」ものだ
結局のところ、E2Eテストに「完璧」なんて存在しない。
100%の信頼性を求めて全てを網羅しようとすれば、メンテナンスコストで自分が潰れる。
1. クリティカルな動線だけをテストする。
2. それ以外はユニットテストに逃がす。
3. テストをコードとして扱い、リファクタリングを怠らない。
E2Eは「書いて終わり」ではない。日々のUI変更や仕様変更に合わせて、泥臭く手入れをし続ける。そうやって「壊れないように育てる」ことこそが、自動化の真髄だ。
コーヒーはもう冷めきったが、ログは全部グリーンになった。
明日もまた、このコードたちと向き合う。それがエンジニアという生き物だろ?