テストコードが「負債」に変わる瞬間 ― E2Eテストを保守可能な資産にするための設計論

テストコードが「負債」に変わる瞬間 ― E2Eテストを保守可能な資産にするための設計論

「またテストが落ちてるぞ」。CIの通知音が鳴るたびに、胸が締め付けられる思いをしたことはないだろうか。

UIを少し変えただけで、数百のテストが芋づる式にレッドになる。修正に追われ、本来書くべき機能開発の時間が削られ、結局「テストなんて書かなきゃよかった」と後悔する。そんな悪循環に陥っているなら、それは君のコードが悪いのではなく、「テストの設計思想」が根本から間違っている可能性が高い。

E2Eテストは、本来エンジニアの最強の防波堤であるはずだ。本稿では、Playwrightを使い倒し、泥臭い試行錯誤の末に辿り着いた「死なないテスト」の書き方を共有する。


1. セレクタの「構造依存」という罠

`div > div > button` といった、DOM構造に依存したセレクタを書くのは今すぐやめるべきだ。UIの設計変更はフロントエンドの宿命であり、それに追従するテストを書くのは骨が折れる。

我々が守るべきは「構造」ではなく「役割」だ。`data-testid` を活用し、テストと実装を疎結合にする。

# 悪い例:DOM構造に依存(UI変更で即座に壊れる)
page.click("div.container > div:nth-child(2) > button")

# 良い例:役割で特定(実装の詳細から分離)
page.get_by_test_id("submit-button").click()

フロントエンドチームと折衝してでも、この属性だけは共通ルールとして徹底させる。これは単なる規約ではなく、長期的な開発効率を守るためのエンジニアとしての防衛線だ。


2. Page Object Model (POM) で操作を部品化する

テストコードがDRY原則を無視し、同じ操作をコピペしているなら、それは保守性の放棄に等しい。画面ごとの操作をクラスに凝縮するPOMの導入は必須だ。

class LoginPage:
    def __init__(self, page):
        self.page = page
        self.email_input = page.get_by_test_id("email")
        self.submit_btn = page.get_by_test_id("login-submit")

    def login(self, email, password):
        self.email_input.fill(email)
        # 必要な操作をカプセル化
        self.submit_btn.click()

# テスト側は非常にシンプルになる
login_page = LoginPage(page)
login_page.login("[email protected]", "password")

POMを導入すれば、ログイン画面の仕様が変わっても修正箇所は1箇所で済む。テストコードもプロダクトコードと同様に、「変更に対して強固であること」が求められるのだ。


3. Storage State:認証のボトルネックを排除する

テストのたびにログイン処理を走らせていないか? 数十、数百のテストケースで毎回APIを叩くのはサーバーサイドへの無駄な負荷であり、CIの時間を浪費する最大の原因だ。

Playwrightの `storageState` を使えば、認証後のクッキーやLocalStorageを永続化し、コンテキストに注入できる。

// 認証状態を保存
await page.context().storageState({ path: 'auth.json' });

// 次のテストではこの状態をロードして実行
const context = await browser.newContext({ storageState: 'auth.json' });

これを導入した際、私の環境ではテスト実行時間が数分から数十秒へと劇的に短縮された。CIの待ち時間は開発者の集中力を削ぐ最大の敵だ。この1分を削る努力が、チームの生産性を大きく変える。


4. 並列実行と「テストの独立性」という厳しい現実

CIを爆速にしようと `workers` を増やした結果、DBの競合やレートリミットに阻まれてテストが不安定になる――これは誰もが一度は通る道だ。

並列実行を成功させる鍵は、「テストデータの完全な分離」にある。

  • DBクリーンアップの徹底: テストごとに独立したトランザクションで実行するか、UUIDを用いた動的なデータ生成を行う。
  • 外部APIのモック: 外部サービスに依存せず、ネットワークの揺らぎを排除する。
  • ログの管理: 失敗した時だけトレースを残す設定を適用する。
// playwright.config.ts
export default defineConfig({
  fullyParallel: true, // 並列実行の恩恵を最大化
  retries: process.env.CI ? 2 : 0, // CIのみリトライを許容
  use: {
    trace: 'on-first-retry', // デバッグ工数を最小化する賢い設定
  },
});

`trace: 'on-first-retry'` は神設定だ。全ログを保存すればストレージはパンクするが、失敗した時だけ詳細を残せば、デバッグ時のストレスは最小限で済む。


結論:テストコードは「部下」である

テストコードは、君が寝ている間もバグを見つけてくれる優秀な部下だ。しかし、設計を怠ればそれは君の時間を奪う「厄介な負債」へと変貌する。

「動けばいい」という考えは捨てろ。「どうすれば変更に耐えうるか」「どうすればCIの時間を短縮できるか」を考え抜いた設計こそが、結果として君の残業時間を減らし、開発の質を担保する。

もし今、君のテストが壊れやすいなら、それは改善のチャンスだ。コードを書き換えるように、テストの設計もリファクタリングし続けろ。それが、エンジニアとして生き残るための唯一の道だ。