もう、手動テストの地獄から抜け出したい。PlaywrightでE2Eテストを「真の自動化」へ導くための泥臭い試行錯誤

もう、手動テストの地獄から抜け出したい。PlaywrightでE2Eテストを「真の自動化」へ導くための泥臭い試行錯誤

深夜2時。モニターの光だけが散らかったデスクを照らしている。
今、目の前にあるのは、新機能の実装が終わったばかりのフロントエンド画面と、それに対する絶望的な量の回帰テスト項目だ。

「このボタンを押して、このフォームを入力して、ちゃんとエラーが出ないか確認して……」

そんなことを毎日、手動で繰り返すのはもう限界だ。特に複雑な状態遷移を伴うSPA(Single Page Application)において、特定のパスを通るたびに手動でクリックを繰り返す作業は、エンジニアの創造性を削り取る「義務的な作業」へと成り果てている。

そんな絶望の中で出会ったのが、Playwrightだった。

最初はCypressと迷っていた。しかし、マルチブラウザ対応の容易さ、高速な実行速度、そして何より「Auto-waiting」の思想が徹底されている点において、現在の開発フローにはPlaywrightが最適だと確信した。

本記事では、単に「ツールを導入した」という表面的な話ではなく、私が実際に現場で血を流しながら(比喩だが)E2Eテストを安定化させる過程で見つけた、実装の勘所やハマりポイントを、ありのままの形でもっとも濃く書き残す。


なぜ「Playwright」だったのか:既存技術との比較

まず、なぜ私が他のツールではなくPlaywrightを選んだのか、その葛藤と結論を整理しておく。E2Eテストの歴史は長く、各ツールには一長一短がある。

特徴SeleniumCypressPlaywright
実行速度低い(WebDriver経由)中〜高(ブラウザ内実行)高い(直接制御、高速なコンテキスト)
待機処理明示的な待機が必要自動待機(ある程度)強力な自動待機(非常に堅牢)
マルチブラウザ非常に強力限定的(主にChromium系)標準でWebkit, Firefox, Chromium対応
モバイルエミュレーション可能だが複雑簡易的なもの高度なエミュレートを標準装備
学習コスト高い(汎用的なため)低め中程度(APIが洗度されている)

結局のところ、私たちは「テストを書きたい」のであって、「テストツールと戦いたい」わけではない。Playwrightは、エンジニアが最も苦労する「要素が現れるまでの待機」や「ネットワークの状態監視」を抽象化してくれる。この思想の差が、運用フェーズでのストレスを大きく左右する。

現場で直面する壁:flaky(不安定)なテストとの戦い

E2Eテストを導入した際に最も最初に突き当たる壁は、「昨日は通ったのに、今日は落ちる」というフラッキーな挙動だ。これはCI環境やネットワークの揺らぎによって発生するが、その原因の多くは「不適切な待機戦略」にある。

1. `sleep` を使うのは最大の罪

初心者がやりがちなのが、要素が見つからないからといって `page.waitForTimeout(3000)` のような固定時間の待機を差し込むことだ。これは最悪の解決策であり、テストの信頼性を根本から破壊する。

Playwrightでは、要素が特定の状態(Visible, Enabledなど)になるまで自動でリトライしてくれるが、それでも複雑な非同期処理(例:APIレスポンスを待ってからDOMが更新される等)がある場合は、「アクションに紐づいた待機」を行うべきだ。

2. ロケーターの選び方の哲学

CSSセレクタやXPathを直接書くのは避けるべきだ。なぜなら、それらは「実装の都合」に依存しすぎるからだ。Playwrightが推奨する `getByRole` や `getByText` を使う理由は、単なる利便性ではない。

これらは「アクセシビリティ(A11y)」に基づいたセレクタであり、ユーザーが見ているものと一致している。これにより、HTML構造が多少変わってもテストが壊れにくくなる。

// 悪い例:CSSクラスや複雑なパスに依存
await page.click('.btn-submit-01_x92'); // クラス名が変われば死ぬ

// 良い例:ユーザーの行動(役割)に基づいたロケーター
await page.getByRole('button', { name: '送信する' }).click();

実践的な構造化:Page Object Model (POM) の導入

テストコードが増えてくると、当然ながらメンテナンス性が課題になる。特定のボタンのセレクタが変わっただけで数十箇所のテストを修正するのは現実的ではない。

ここで採用するのが Page Object Model (POM) だ。ページ(またはコンポーネント)の操作をクラスとして定義し、テスト本体とは分離する。これにより、「何をするか」と「どう操作するか」の責務を分離できる。

以下は、ログインフローを例にした具体的な実装構造である。

// models/LoginPage.ts
import { Page, Locator }/ from '@playwright/test';

export class LoginPage {
  private readonly page: Page;
  private readonly usernameInput: Locator;
  private readonly passwordInput: Locator;
  private readonly loginButton: Locator;

  constructor(page: Page) {
    this.page = page;
    // コンストラクタで定義することで、型安全に操作を記述できる
    this.usernameInput = page.getByLabel('ユーザー名');
    this.passwordInput = page.getByLabel('パスワード');
    this.loginButton = page.getByRole('button', { name: 'ログイン' });
  }

  async navigate() {
    await this.page.goto('https://example.com/login');
  }

  async login(username: string, password: string) {
    await this.usernameInput.fill(username);
    await this.passwordInput.fill(password);
    await this.loginButton.click();
  }
}

この構成をとることで、テストコードは以下のように非常に読みやすくなる。

// tests/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../models/LoginPage';

test('ユーザーが正しくログインできること', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.navigate();
  await loginPage.login('test_user', 'password123');
  
  // ログイン後の状態を検証
  await expect(page).toHaveURL(/.*dashboard/);
  await expect(page.getByText('ようこそ')).toBeVisible();
});

このように抽象化することで、UIの微修正があっても `LoginPage` クラスの定義を直すだけで全テストに対応できるようになる。これは大規模開発において不可欠な防衛策だ。

ネットワークの制御とモック(Mocking)

E2Eテストのもう一つの大きな罠は、バックエンドAPIの不安定さである。外部APIや、まだ構築中のマイクロサービスに依存している場合、環境によってテストが失敗する。

Playwrightには `page.route()` という強力な武器がある。これを使うことで、特定のAPIリクエストをインターセプトし、あらかじめ用意したレスポンスを返すことができる。

「バックエンドの準備ができるまでフロントエンドのE2Eテストを止めたくない」という状況において、このモック機能は救世主となる。特に、エラー系の挙動(500エラーや403 Forbiddenなど)を意図的に発生させて検証する際に威力を発揮する。

// 特定のAPIリクエストをインターセプトしてモックする例
await page.route('**/api/v1/user-profile', async (route) => {
  const json = { id: 1, name: 'Mock User' };
  await route.fulfill({ json });
});

CIへの統合と「見えないエラー」の解決

ローカル環境で動くテストが、GitHub ActionsなどのCI環境で落ちる。これはE2E自動化における「あるある」な苦しみだ。原因は多岐にわたるが、主に以下の3点に集約される。

1. Headlessモードの違い:
ヘッドレス環境ではレンダリングのタイミングやフォントの扱いが微妙に異なることがある。Playwrightはこの差異を吸収するよう設計されているが、それでもDockerコンテナ内など特殊な環境では挙動が変わる場合がある。
2. リソースの競合:
CI上では並列実行を行うことが多いため、同じデータに対する同時アクセスでエラーになる。テストデータを用意する際に「一意のID」を使うなどの設計が必要になる。
3. タイムアウトとネットワーク遅延:
ローカルより遅いCI環境では、デフォルトのタイムアウト(通常30秒など)に間に合わないことがある。

これらに対処するための最強の武器が Playwright Trace Viewer だ。
CIで失敗した際に、その瞬間のスクリーンショット、動画、コンソールログ、ネットワークリクエストをすべて含んだ「トレースファイル」を出力するように設定する。これにより、「なぜCIで落ちたのか?」を追いかけるための時間を劇的に削減できる。

GitHub Actionsの構成例(抜粋)

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
      - name: Install dependencies
        run: npm ci
      - name: Install Playwright Browsers
        run: npx playwright install --with-deps
      - name: Run tests
        run: npx playwright test
      - uses: actions/upload-artifact@v3
        if: always()
        with:
          name: playwright-report
          path: build/playwright-report/

最後に:自動化は「終わり」ではなく「継続」のプロセス

E2Eテストを導入しただけで満足してはいけない。プロダクトが成長し、機能が増えるたびに、テストコードもメンテナンスされなければならない。

最初は「動くこと」を確認するだけのスクリプトでいい。しかし、チームの規模が大きくなれば、それを堅牢なアセットへと昇華させるための設計(POMやモックの活用)が必要になる。

深夜の静寂の中で書いたこのメモが、誰かの「手動テストからの脱却」を後押しするきっかけになれば幸いである。自動化の道は険しいが、一度軌道に乗ったときの安心感は、何物にも代えがたいものだからだ。