言語を切り替える
テーマを切り替える

Next.js E2E テスト:Playwright 自動化テスト実践ガイド

午前3時。バグチケットに貼られた「緊急」の赤いラベルを見つめながら、私は本番環境で同じページを3回リロードしていました。決済フローがまた壊れたのです。テスト環境では完璧に動いていたのに、なぜ本番だけで動かないのか?

先週のリリースを思い出します。30以上のページを手動でクリックし、十数個のフォームを入力し、3種類のブラウザを行き来しました。それだけやったのに、モバイル端末で一番下までスクロールしないと表示されないボタンの不具合を見逃していました。プロダクトマネージャーからの Slack 通知がポップアップします。「ユーザーからクーポンが使えないって言われてるんだけど」。胃がキリキリと痛み始めました。

その夜、私は決心しました。E2E テストを導入しなければならない。これ以上、人海戦術の手動テストを続けていたら、チームも自分も倒れてしまう。

善は急げ。選定にあたって Cypress、Selenium、Puppeteer、Playwright 一通り検討しました。最終的に Playwright を選んだ理由は、マルチブラウザ対応の強力さと、Cypress よりも設定がシンプルだったからです。導入して最初の一週間で、Playwright は手動テストでは見つけられなかった5つのバグを見つけてくれました。Firefox だけで崩れるレイアウトや、非同期 API の競合状態などです。

この記事では、私が半年間 Next.js プロジェクトで Playwright を運用して得た知見を共有します。正直に言うと、最初は設定ファイルを何度も書き直したり、テストケースを全書き換えしたりと、かなりの試行錯誤がありました。でも今ではフローは完全にスムーズになり、CI/CD で自動化され、コミットのたびにテストが走り、本番のバグは劇的に減りました。

なぜ Playwright を選んだのか(vs Cypress)

Cypress を使ったことがある人なら、その設定の手軽さやドキュメントの親切さはご存知でしょう。では、なぜ私は最終的に Playwright を選んだのでしょうか?

主な理由は3つあります:

1. クロスブラウザサポートの弱さ:Cypress の Firefox や Safari (WebKit) 対応は、長らく実験的だったり不安定だったりしました。基本は Chromium ベースです。これには痛い目に遭わされました。Chrome で完璧に動く決済ページが、Safari で真っ白になったのです。原因は Safari 未対応の CSS プロパティでした。Playwright は Chromium、Firefox、WebKit の3大エンジンをネイティブサポートしており、1セットのテストコードで主要ブラウザをカバーできます。

2. テスト速度:Playwright の並列実行能力は圧倒的です。Cypress は基本直列実行で、50ケース走らせるのに十数分かかっていました。Playwright で 8 worker 並列にすると、同じケースが5分で終わります。CI/CD の実行時間はコストに直結するので、この差は大きいです。

3. API デザイン:正直、Cypress のチェーンメソッド(jQueryライク)から Playwright に移行した当初は戸惑いました。しかし、Playwright の async/await スタイルに慣れると、現代的な JavaScript の書き方に近く、Next.js の Server Components のコードスタイルとも一貫性があることに気づきました。

とはいえ、Cypress が悪いわけではありません。Chrome だけ動けばよく、チームがテストに不慣れなら、Cypress の方が学習コストは低いでしょう。タイムトラベル機能(Time Travel)によるデバッグは本当に素晴らしいです。

しかし、私の要件には Playwright が合っていました:

  • クロスブラウザテストが必須
  • Next.js/React の経験があり、async/await に抵抗がない
  • CI 環境での高速なフィードバックが必要
  • API ルートや SSR ページのテストもしたい

結論:小規模プロジェクトや初心者チームなら Cypress で素早く始めるのが吉。ある程度の規模があり、長期的に自動化テストに投資するなら Playwright がベストチョイスです。

Next.js + Playwright 設定実践

Playwright のインストールは超簡単です。コマンド3つで終わります:

npm init playwright@latest
# または pnpm
pnpm create playwright

インストール中にいくつか質問されます。おすすめの回答はこちら:

  • TypeScript? Yes(型定義の恩恵は絶大です)
  • テストディレクトリ? tests(デフォルトでOK)
  • GitHub Actions? Yes(CI/CD で使います)

インストールが完了すると、以下のファイルが生成されます:

your-nextjs-project/
├── tests/               # テストケース格納ディレクトリ
│   └── example.spec.ts
├── playwright.config.ts # Playwright 設定ファイル
└── .github/
    └── workflows/
        └── playwright.yml  # CI 設定ファイル

設定ファイルの落とし穴と最適解

初期生成される playwright.config.ts は一般的な Web プロジェクト用です。Next.js プロジェクト用に最適化した、私の「半年間の集大成」設定がこちらです:

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

export default defineConfig({
  // テストディレクトリ
  testDir: './tests',

  // グローバルタイムアウト:1テストあたり30秒
  timeout: 30 * 1000,

  // アサーションタイムアウト:要素が見つかるまで5秒待つ
  expect: {
    timeout: 5000,
  },

  // 失敗時のリトライ回数(CI 環境ではオンにする)
  retries: process.env.CI ? 2 : 0,

  // 並列 worker 数(私のマシンは8コアなので4に設定)
  workers: process.env.CI ? 2 : 4,

  // レポート形式
  reporter: [
    ['html'],                    // HTML レポート生成
    ['list'],                    // ターミナルリスト出力
    process.env.CI ? ['github'] : ['list'], // CI では GitHub Annotation 形式
  ],

  // Next.js 開発サーバーの起動設定
  webServer: {
    command: 'npm run dev',
    port: 3000,
    timeout: 120 * 1000,         // 初回ビルドは時間がかかるので長めに
    reuseExistingServer: !process.env.CI, // ローカルでは既存サーバーを再利用
  },

  // テストプロジェクト(ブラウザ別設定)
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    // モバイルテスト(必要に応じて)
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 5'] },
    },
  ],

  // グローバル設定
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',      // 失敗時のみトレースを記録(デバッグ神機能)
    screenshot: 'only-on-failure', // 失敗時のみスクショ
    video: 'retain-on-failure',   // 失敗時のみ録画
  },
});

重要なポイント

  1. webServer.timeout は長めに:最初は30秒にしていましたが、Next.js の初回ビルド(特に大規模アプリ)は遅く、タイムアウトで落ちまくりました。120秒にしておけば安心です。

  2. reuseExistingServer: true を忘れずに:これがないと、テスト実行のたびに Next.js サーバーが再起動してしまい、開発テンポが悪くなります。ローカルでは true 推奨です。

  3. workers は欲張らない:CPU コア数全開にすると、PC がフリーズすることがあります。コア数の半分くらいが適正です。

  4. モバイルテストは任意:レスポンシブ対応が重要なアプリなら Mobile Chrome を入れるべきですが、テスト時間は倍増します。プロジェクトの要件と相談してください。

設定ができたら、サンプルを実行してみましょう:

npx playwright test

緑色の passed が出れば環境構築完了です。

画面操作テストのベストプラクティス(Page Object Model)

初心者がやりがちなミスは、テストコード内に直接セレクタを書きまくることです。ログインページのテストで 100 行以上のスパゲッティコードを書き、input[name="email"] があちこちに散乱している…なんてこと、ありませんか? デザイナーが ID を変えた瞬間、全テストが壊れて地獄を見ます。

これを防ぐのが Page Object Model (POM) です。ページ操作をクラスにカプセル化するパターンです。

POM を使わない悪い例

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

test('ユーザーログイン', async ({ page }) => {
  await page.goto('/login');

  // 要素を直接操作(重複多数、変更に弱い)
  await page.locator('input[name="email"]').fill('[email protected]');
  await page.locator('input[name="password"]').fill('password123');
  await page.locator('button[type="submit"]').click();

  await expect(page.locator('h1')).toContainText('Dashboard');
});

POM でリファクタリング(推奨)

まず Page Object クラスを作ります:

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

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;
  readonly dashboardTitle: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.locator('input[name="email"]');
    this.passwordInput = page.locator('input[name="password"]');
    this.submitButton = page.locator('button[type="submit"]');
    this.errorMessage = page.locator('.error');
    this.dashboardTitle = page.locator('h1');
  }

  // 操作をメソッド化
  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }

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

  // アサーションもメソッド化
  async expectLoginSuccess() {
    // 画面遷移を待つ
    await this.dashboardTitle.waitFor();
    await expect(this.dashboardTitle).toContainText('Dashboard');
  }

  async expectLoginError() {
    await expect(this.errorMessage).toBeVisible();
  }
}

テストコードはこう変わります:

// tests/login.spec.ts
import { test } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

test('ユーザーログイン', async ({ page }) => {
  const loginPage = new LoginPage(page);

  await loginPage.goto();
  await loginPage.login('[email protected]', 'password123');
  await loginPage.expectLoginSuccess();
});

どうですか? “コードがドキュメントのように読める” ようになりました。セレクタが変わっても LoginPage.ts を一行直すだけで全テストが修正されます。

ディレクトリ構成例

tests/
├── pages/                  # Page Objects
│   ├── LoginPage.ts
│   ├── DashboardPage.ts
│   └── CheckoutPage.ts
├── fixtures/               # テストデータ
│   └── testData.ts
├── auth.spec.ts           # 認証テスト
└── checkout.spec.ts       # 購入テスト

API ルートの E2E テスト

Next.js の強みは API Routes ですが、これももちろんテスト対象です。Postman でポチポチ手動テストするのは卒業しましょう。Playwright はブラウザ操作だけでなく、強力な HTTP リクエスト機能も持っています。

基本的な API テスト

// tests/api/users.spec.ts
import { test, expect } from '@playwright/test';

test.describe('ユーザー API テスト', () => {
  test('GET /api/users - リスト取得', async ({ request }) => {
    const response = await request.get('/api/users');

    // ステータスコード検証
    expect(response.status()).toBe(200);

    // ボディ検証
    const users = await response.json();
    expect(Array.isArray(users)).toBeTruthy();
    expect(users.length).toBeGreaterThan(0);
    expect(users[0]).toHaveProperty('email');
  });

  test('POST /api/users - 重複登録エラー', async ({ request }) => {
    const duplicateUser = {
      email: '[email protected]',
      password: 'password123'
    };

    const response = await request.post('/api/users', {
      data: duplicateUser
    });

    expect(response.status()).toBe(400);
    const error = await response.json();
    expect(error.message).toContain('既に使用されているメールアドレスです');
  });
});

画面操作 + API 検証のハイブリッドテスト

これが最強のパターンです。「画面から記事を投稿し、API でデータベースに正しく保存されたか確認する」といったシナリオです。

// tests/posts.spec.ts
test('記事投稿の完全フロー', async ({ page, request }) => {
  // 1. 画面操作:ログインして記事投稿
  await page.goto('/login');
  // ... ログイン処理 ...
  await page.goto('/posts/new');
  await page.fill('input[name="title"]', 'テスト記事');
  await page.click('button:has-text("公開")');

  // 2. 完了画面への遷移待機
  await page.waitForURL(/\/posts\/\d+/);

  // 3. API 検証:データが本当に作られたか?
  const url = page.url();
  const postId = url.split('/').pop();

  const response = await request.get(`/api/posts/${postId}`);
  expect(response.status()).toBe(200);

  const post = await response.json();
  expect(post.title).toBe('テスト記事');
  expect(post.status).toBe('published'); // ステータスもチェック
});

この方法で、「画面上は『成功』と出ているのに、DB上は下書き状態のまま」という恐ろしいバグを見つけたことがあります。フロントエンドとバックエンドの整合性をチェックできるのは E2E ならではです。

CI/CD (GitHub Actions) 統合

テストは自動で走ってこそ意味があります。npm init playwright で生成されるワークフローは基本的すぎるので、本番運用に耐えるように強化した設定を紹介します。

本番級 CI 設定 (.github/workflows/playwright.yml)

name: E2E Tests

on:
  push:
    branches: [ main, dev ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    timeout-minutes: 60
    runs-on: ubuntu-latest

    services:
      # テスト用データベース (PostgreSQL)
      postgres:
        image: postgres:15
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: testdb
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

    env:
      DATABASE_URL: postgresql://test:test@localhost:5432/testdb
      NODE_ENV: test

    steps:
    - uses: actions/checkout@v4

    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: '20'
        cache: 'npm'

    - name: Install dependencies
      run: npm ci

    # ブラウザバイナリのキャッシュ(高速化の鍵)
    - name: Cache Playwright browsers
      uses: actions/cache@v3
      with:
        path: ~/.cache/ms-playwright
        key: ${{ runner.os }}-playwright-${{ hashFiles('**/package-lock.json') }}

    - name: Install Playwright Browsers
      run: npx playwright install --with-deps chromium

    - name: Run database migrations
      run: npm run db:migrate

    - name: Run Playwright tests
      run: npx playwright test

    # テスト結果(HTMLレポート、スクショ、Trace)を保存
    - name: Upload test results
      if: always()
      uses: actions/upload-artifact@v4
      with:
        name: playwright-report
        path: playwright-report/
        retention-days: 30

設定の改善点解説

  1. Service Container (Postgres):API テストが落ちないよう、CI 内で本物の DB を立てます。MySQL なら mysql:8 に変えてください。
  2. ブラウザキャッシュ:Playwright のブラウザダウンロードは時間がかかります。actions/cache でキャッシュすることで、2回目以降の実行を高速化します。
  3. DB Migration:テスト実行前に npm run db:migrate (Prisma など) でテーブルを作ります。
  4. Artifact Upload:テスト失敗時、スクリーンショットや Trace ファイルを GitHub Artifacts として保存し、後でダウンロードして調査できるようにします。

デバッグの神機能:Trace Viewer

CI でテストが落ちたとき、ログだけ見ても「何が起きたか」さっぱりわかりません。そこで Playwright の Trace Viewer の出番です。

私の playwright.config.ts 設定では trace: 'on-first-retry' になっています。これは「一度失敗してリトライした時、詳細なトレースを取る」という設定です。生成された trace.zip を開くと:

  • テスト実行のタイムライン
  • 各ステップでの画面スナップショット(DOMも見れる)
  • ネットワークリクエスト詳細
  • コンソールログ

これらがプレイヤーのように再生できます。「ああ、このボタンをクリックした瞬間、API が 500 エラーを返したから次に行けなかったのか」と一発で分かります。これを知ってからデバッグ時間が10分の1になりました。

まとめ

Playwright による E2E テストの導入は、最初は少し学習コストがかかりますが、そのリターンは計り知れません。「リリース前の胃の痛み」から解放される最良の投資です。

まずは主要なハッピーパス(正常系)、例えば「ログインして、商品を選んで、購入する」といったコア機能からテストを書き始めてください。カバレッジ100%を目指すのは後でいいです。動くテストが一つあるだけで、安心感は段違いですから。

FAQ

Cypress と Playwright、今から始めるならどっち?
2026年現在なら Playwright を強く推奨します。クロスブラウザ対応(特に Safari)、実行速度、パラレル実行の並列度において Cypress を凌駕しています。また、Microsoft が活発にメンテナンスしており、VS Code との統合も強力です。
E2E テストは不安定(Flaky)になりがちですが?
Playwright には自動待機(Auto-waiting)機能があり、要素が表示されるまでクリックを待つなど、Flaky さを減らす工夫がされています。それでも不安定な場合は、固定時間の `waitForTimeout` ではなく、`waitForSelector` や `waitForURL`、`expect(...).toBeVisible()` などの状態待ちアサーションを使うよう徹底してください。
すべてのページに Page Object を作るべきですか?
いいえ、過度な抽象化は不要です。複数のテストで再利用されるページ(ログイン、共通ヘッダー、複雑なダッシュボードなど)のみ POM 化し、一度しかテストしない単純なページは直接ごり書きで十分です。

4 min read · 公開日: 2026年1月7日 · 更新日: 2026年1月22日

コメント

GitHubアカウントでログインしてコメントできます

関連記事