テスト講座 #6 PlaywrightでE2EとCI統合 — トラックの締めくくり

読了 15分

テストトラックの最終回です。#1〜5 が一つのコンポーネント / 一つのモジュール内に閉じていたのに対し、今回は 本物のブラウザで実際のユーザーシナリオ を最後まで追いかけるE2Eです。

テスト講座 におけるこの記事の位置:

今回のツールはPlaywright、最後の節でトラック6本全体を振り返ります。

E2Eが捕まえるもの、捕まえないもの #

#1 で示した形をもう一度。E2Eの位置はこちらです。

再び — トロフィー
        ┌──────────────┐
        │     E2E      │      ← 主要シナリオ 5〜10個
        ├──────────────┤
        │  結合 (RTL)   │
        ├──────────────┤
        │   ユニット     │
        ├──────────────┤
        │    静的       │
        └──────────────┘

E2Eが 捕まえるもの

  • コンポーネント結合テストでは見えないもの — ルーティング、認証フロー、ページ間の状態の受け渡し。
  • 本物のブラウザ差 — SafariがChromeと違う動きをする細かな部分。
  • バックエンドとフロントエンドの契約がずれている部分。
  • 本物のネットワーク / 本物のDBが動く環境でしか見えないrace / timing。

E2Eが 捕まえにくい、または費用対効果が悪いもの

  • 単一コンポーネントの動作 — RTLのほうが速くて正確。
  • エッジケースの全分岐 — E2E 30個でカバーする分岐をユニット5個でカバーできる。
  • サーバーサイドのビジネスロジック — サーバーサイドのユニット/結合テストが本筋。

推奨数 — 主要なユーザーフロー 5〜10個。「会員登録 → ログイン → 主要機能を1〜2個 → 決済」くらい。それ以上に増やすと、メンテナンスがすぐに重荷になります。

Playwright vs Cypress — 短く #

E2Eツールはこの2強です。要点だけ示すと、

PlaywrightCypress
ブラウザChromium, Firefox, WebKit (Safari)Chromium, Firefox, WebKit (実験的)
実行モデルOut-of-process(ブラウザ自動化)In-process(ブラウザ内部)
マルチタブ / iframe自然にサポート扱いにくい
並列実行標準サポート有料 (Cypress Cloud)
モバイルエミュレーション強い普通
学習コスト低い最も低い
ドキュメント良い非常に良い

新規プロジェクトでPlaywrightを選ばない理由がほぼない 状況になりました。WebKitサポート、無料の並列実行、Microsoftによる活発な開発。Cypressが強いのは、教えやすいDXとビジュアルデバッガくらいです。

この記事はPlaywrightベースで進めます。

セットアップ #

最も速いスタート — 公式のinitコマンドです。

Playwrightのインストール
pnpm create playwright

聞かれること:

  • TypeScript? → はい
  • テストディレクトリ? → e2e(または tests
  • GitHub Actionsワークフローを追加? → はい(CIの節で詳しく)
  • ブラウザのインストール? → はい(Chromium / Firefox / WebKitをダウンロード、約500MB)

生成された playwright.config.ts の一部:

playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:5173',
    trace: 'on-first-retry',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
  ],
  webServer: {
    command: 'pnpm dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI,
  },
});

主要なオプション:

  • baseURL — すべての page.goto('/') の基準。環境ごとに違うURLを環境変数で差し替えます。
  • webServer — テスト開始前に自動でdevサーバーを立ち上げてくれます。CIでも同じ動作です。
  • projects — 同じテストを複数のブラウザで。CIでは3つすべて回すのが定石です。
  • trace: 'on-first-retry' — 最初に失敗したテストだけtraceを記録(スクリーンショット + DOMスナップショット + ネットワーク)。成功ケースは記録しないので結果が軽くなります。
  • retries — flakyなテストの自動リトライ。CIでのみ有効。

最初のE2E #

最も単純なテストです。

e2e/home.spec.ts
import { test, expect } from '@playwright/test';

test('ホームページがタイトルを表示する', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveTitle(/My App/);
});

test('ログインリンクをクリックするとログインページへ遷移する', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('link', { name: 'ログイン' }).click();
  await expect(page).toHaveURL('/login');
});

{ page } はテストごとに新しいブラウザコンテキストです。クリーンなクッキー / ストレージで開始します。

queriesはRTLとほぼ同じ形 — getByRolegetByLabelgetByText。同じ哲学を共有しています。

実行
pnpm playwright test                  # headless
pnpm playwright test --headed         # ブラウザを表示
pnpm playwright test --ui             # ビジュアルUIモード(強力)
pnpm playwright test --debug          # デバッグモード(ステップ実行)

--ui モードを一度立ち上げてみてください。左にテスト一覧、中央にブラウザ画面、右にアクションタイムライン。デバッグの体験ががらりと変わります。

Locator — 単なるelementではない #

Playwrightの getByRole(...) などは、実は Locator オブジェクトを返します。RTLの結果は即座にelementですが、Playwrightはlazyです — 実際にアクションが起きるまでelementを探しません。

locatorの位置
const button = page.getByRole('button', { name: '保存' });
// この時点ではまだDOMを見ていない

await button.click();
// このタイミングでelementを探し、見つからなければ自動で待つ

この構造のおかげで 自動待機(auto-waiting) が自然になります。RTLの waitFor / findBy を使う必要はほぼありません — await 自体がelementを待ってくれるためです。

auto-waitingの例
await page.getByRole('button', { name: '保存' }).click();
// ボタンが現れるのを待ち、enabledになるのを待ってクリック

await expect(page.getByRole('alert')).toHaveText('保存しました');
// alertが現れるのを待ち、テキストが一致するまで待つ

expect も同じ形です。await expect(locator).toBeVisible() はデフォルトで5秒間ポーリングしながら待ち、最後まで満たされなければそこで失敗します。

ログインフロー — 本物のE2E #

もう少し本格的なシナリオです。ログイン → ダッシュボード → ログアウト。

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

test.describe('認証フロー', () => {
  test('間違ったパスワードでログインするとエラーが表示される', async ({ page }) => {
    await page.goto('/login');

    await page.getByLabel('メール').fill('user@example.com');
    await page.getByLabel('パスワード').fill('wrong');
    await page.getByRole('button', { name: 'ログイン' }).click();

    await expect(page.getByRole('alert')).toContainText('パスワード');
    await expect(page).toHaveURL('/login'); // ページ遷移なし
  });

  test('正しい資格情報でログインするとダッシュボードへ遷移する', async ({ page }) => {
    await page.goto('/login');

    await page.getByLabel('メール').fill('user@example.com');
    await page.getByLabel('パスワード').fill('correct-password');
    await page.getByRole('button', { name: 'ログイン' }).click();

    await expect(page).toHaveURL('/dashboard');
    await expect(page.getByRole('heading', { name: /ようこそ/ })).toBeVisible();
  });

  test('ダッシュボードからログアウトするとホームに戻る', async ({ page }) => {
    // 事前ログイン
    await page.goto('/login');
    await page.getByLabel('メール').fill('user@example.com');
    await page.getByLabel('パスワード').fill('correct-password');
    await page.getByRole('button', { name: 'ログイン' }).click();
    await expect(page).toHaveURL('/dashboard');

    // ログアウト
    await page.getByRole('button', { name: 'ログアウト' }).click();
    await expect(page).toHaveURL('/');
  });
});

3つ目のテストが面白いところです — 毎回事前ログインを繰り返さなければなりません。E2Eがすぐに重荷になる部分です。次の節で解決します。

storageState — ログイン状態の共有 #

毎テストでログインフローを回すのは無駄です。Playwrightの storageState がこれを解決します。

e2e/auth.setup.ts
import { test as setup } from '@playwright/test';

const authFile = '.auth/user.json';

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('メール').fill('user@example.com');
  await page.getByLabel('パスワード').fill('correct-password');
  await page.getByRole('button', { name: 'ログイン' }).click();

  await page.waitForURL('/dashboard');

  // クッキーとlocalStorageをファイルに保存
  await page.context().storageState({ path: authFile });
});
playwright.config.ts — projectsを分割
projects: [
  { name: 'setup', testMatch: /.*\.setup\.ts/ },
  {
    name: 'chromium',
    use: { ...devices['Desktop Chrome'], storageState: '.auth/user.json' },
    dependencies: ['setup'],
  },
],

これですべてのchromiumテストが すでにログイン済みの状態 で開始します。setupは1回だけ動き、本テストたちはその結果を共有します。

ネットワークモッキング — Playwrightでも可能 #

E2Eだからといって常に本物のバックエンドを使う必要はありません。一部のシナリオ(エラーレスポンス、遅いレスポンス)はモッキングが自然です。

ネットワークの傍受
test('サーバーが500を返すとエラー画面が出る', async ({ page }) => {
  await page.route('/api/posts', (route) => {
    route.fulfill({ status: 500, body: 'Internal Error' });
  });

  await page.goto('/posts');
  await expect(page.getByRole('alert')).toContainText('サーバーエラー');
});

page.route(pattern, handler)#4 のMSWに似た位置です。ただしE2Eの本来の価値は 本物のバックエンドの検証 にあります。モッキングは普段は使わず、エラーパス / エッジケースを1〜2個程度に抑えるのがおすすめです。

Page Object パターン — 軽めに #

テストが増えると、同じselectorをテストごとに繰り返すことになります。Page Objectパターンがここを整理してくれます。

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

export class LoginPage {
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly alert: Locator;

  constructor(public readonly page: Page) {
    this.emailInput = page.getByLabel('メール');
    this.passwordInput = page.getByLabel('パスワード');
    this.submitButton = page.getByRole('button', { name: 'ログイン' });
    this.alert = page.getByRole('alert');
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}
使い方
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

test('ログインフロー', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@example.com', 'correct-password');

  await expect(page).toHaveURL('/dashboard');
});

selectorが一箇所にまとまり、変更時は1箇所だけ修正すれば済みます。1つだけ注意 — 過度な抽象化はかえって可読性を損ねます。selectorとよく使うアクション までをPOに。検証(expect)はテストの中に置いたほうが、たいていは見通しが良いです。

CI統合 — GitHub Actions #

pnpm create playwright が自動で生成してくれるワークフローです。

.github/workflows/playwright.yml
name: Playwright Tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    timeout-minutes: 30
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22

      - uses: pnpm/action-setup@v4

      - name: Install dependencies
        run: pnpm install

      - name: Install Playwright browsers
        run: pnpm exec playwright install --with-deps

      - name: Run tests
        run: pnpm exec playwright test

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

要点:

  • playwright install --with-deps — ブラウザ + システム依存関係をまとめてインストール。CIごとにキャッシュできる部分なので、キャッシュアクションを追加するとさらに速くなります。
  • upload-artifact with if: always() — テストが落ちたときにHTMLレポートを成果物としてアップロード。落ちたテストのtrace / screenshotをPRからダウンロードして確認できます。

Vitestも一緒に — 1つのワークフローに両方 #

E2Eとユニット / 結合を同じワークフローに収めます。

統合ワークフロー
name: Tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22 }
      - uses: pnpm/action-setup@v4
      - run: pnpm install
      - run: pnpm test:run --coverage
      - uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage/

  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22 }
      - uses: pnpm/action-setup@v4
      - run: pnpm install
      - run: pnpm exec playwright install --with-deps
      - run: pnpm exec playwright test
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/

並列で回るのでunit(数十秒)とe2e(数分)が同時に終わります — PRのフィードバックサイクルが短縮されます。

カバレッジ — どこまで見せるか #

VitestのカバレッジレポートがCIに上がると、GitHub PRに自動でコメントを付けてくれるアクションがよく使われます。

カバレッジコメント
- uses: davelosert/vitest-coverage-report-action@v2
  if: github.event_name == 'pull_request'

PRに「このPRでカバレッジが X% → Y% に変化」のようなコメントが自動で付きます。

#1#2 でも触れた話 — 数字に振り回されないこと。カバレッジが下がったら自動でマージブロック、のようなルールはたいてい自分の足を撃つことになります。主要なフローが押さえられているかのほうが大切です。

Visualリグレッション — 一行 #

画面が意図に反して変わっていないかを捕まえたいなら — Playwrightの toHaveScreenshot() です。

visual snapshot
test('ホームページのビジュアルリグレッション', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveScreenshot('home.png');
});

初回実行時にbaselineスクリーンショットが保存され、以降の実行で差分があれば失敗します。CIのOS / フォントの違いがfalse positiveをよく引き起こすので、通常は同じOSでbaselineを作り、それだけと比較します。

規模が小さいプロジェクトでは、わざわざ導入しなくてもOKです。大きくなったらPercy / ChromaticのようなSaaSのほうがすっきりします。

よくある落とし穴 #

flakyなテストが多すぎる — 多くは setTimeout / network race / animationが原因です。Playwrightの expect(...).toBe...() が自動待機を行うため、page.waitForTimeout(1000) のようなfixed sleepはほぼ常にアンチパターンです。

テストはパスするのにcookies / storageが漏れているように見えるstorageState が正しく設定されていないか、test.use({ storageState: ... })dependencies の組み合わせがズレているケースです。

CIでだけ落ちる — たいていtiming / 画面サイズ / fontsです。playwright.config.tsviewportbaseURL がCIとローカルで同じか確認しましょう。

.toHaveText() がテキストの一部で失敗toContainText() と取り違えています。完全一致は toHaveText、部分一致は toContainText

並列実行でデータ競合 — 同じテストユーザーが同時に動いている流れです。テストごとに別データを使うか(メールにtimestampを足すなど)、test.describe.configure({ mode: 'serial' }) で直列化します。

WebKitだけ落ちる — Safariの微妙な互換性の差です。playwright.config.ts で一時的に projects からwebkitを外し、別途デバッグします。たいていはCSS / transform / focus関連です。

トラック6本の振り返り #

#1 の図から始め — 静的 → 結合 → ユニット → E2E の配分に沿って進めてきました。6本がそれぞれ触れたところ:

シリーズが触れたところ
#1 — 図(ピラミッド vs トロフィー)
#2 — Vitest(ユニット + そのセットアップ)
#3 — RTL(結合への第一歩)
#4 — MSW(ネットワーク傍受 = 結合の核)
#5 — userEvent + フォーム(ユーザー入力の結合)
#6 — Playwright + CI(E2E + 自動化)

そして最初に示した一文に戻ると、

テストができない理由はたいてい「忙しいから」ではなく、何を / どこで / どうやって の絵がないからです。

その絵がもう手元にあります。小さな関数にユニットテストを書くか、コンポーネント結合に進むか、E2Eシナリオに入れるか — 判断のガイドが手の中にあります。ここから先に進むところ:

  • 状態管理の深掘りトラック — TanStack Query / Zustandのようなツールのテストパターン。
  • バックエンドのテスト — Pythonトラックの pytestDjango中級 #7 テストモダンPython実践 #6 が該当箇所です。
  • コントラクトテスト — バックエンドとフロントエンドの契約をOpenAPI / Pactで検証。
  • mutation testing — テストが「本当に捕まえているか」を検証するメタツール(Stryker)。

まとめ #

  • E2Eは 主要なユーザーフロー 5〜10個 — それ以上はメンテナンスが負担になります。ユニット / 結合が見るべきものはそちらに任せましょう。
  • Playwrightのlocatorはlazy + auto-waiting。waitFor はほぼ不要 — await expect(locator).toBe...() が自動でポーリングします。
  • queriesはRTLと同じ哲学 — getByRolegetByLabel を優先。ユーザーが見る方法に揃えます。
  • storageState でログイン状態を共有 — 毎テストでログインフローを繰り返さないようにします。
  • page.route でPlaywright内でもネットワークモッキングが可能。ただしE2Eの本来の価値は本物のバックエンドの検証にあります。
  • CIワークフローは unit + e2e の並列。trace / reportをartifactとしてアップロードし、PRからダウンロードできるようにしましょう。
  • カバレッジは 参考、自動ブロックのルールはたいてい自分の足を撃つことになります。
  • トラック6本の形 — 静的 → 結合 → ユニット → E2E。時間の配分を意識的に。

テストトラックはここで締めくくります。Reactトラック / TypeScriptトラック からの自然な拡張先でした。このトラックを終えれば、ほぼすべてのReactコードに対してテストを書く感覚が手に馴染んでいるはずです。次の一歩は — 自分のプロジェクトの一つに実際に導入してみる段階。小さな関数1〜2個から始めましょう。

X