Documentation Next.js

はじめに

E2E(エンドツーエンド)テストは、アプリケーション全体の動作を検証する重要なテスト手法です。この記事では、Next.js App RouterでCypressとPlaywrightを使用したE2Eテストの実装方法を解説します。

Playwrightでのテスト

セットアップ

# Playwrightのインストール
npm init playwright@latest

# 必要なブラウザをインストール
npx playwright install

設定ファイル

// 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'],
    ['json', { outputFile: 'test-results/results.json' }],
  ],
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },

  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'] },
    },
    {
      name: 'Mobile Safari',
      use: { ...devices['iPhone 12'] },
    },
  ],

  // Next.jsサーバーを自動起動
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120 * 1000,
  },
});

基本的なテスト

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

test.describe('ホームページ', () => {
  test('タイトルが正しく表示される', async ({ page }) => {
    await page.goto('/');

    await expect(page).toHaveTitle(/Next.js/);
    await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
  });

  test('ナビゲーションが機能する', async ({ page }) => {
    await page.goto('/');

    // About ページへの遷移
    await page.getByRole('link', { name: '会社概要' }).click();
    await expect(page).toHaveURL('/about');
    await expect(page.getByRole('heading', { name: '会社概要' })).toBeVisible();
  });

  test('検索機能が動作する', async ({ page }) => {
    await page.goto('/');

    // 検索ボックスに入力
    const searchInput = page.getByRole('searchbox');
    await searchInput.fill('Next.js');
    await searchInput.press('Enter');

    // 検索結果が表示される
    await expect(page).toHaveURL('/search?q=Next.js');
    await expect(page.getByText('検索結果')).toBeVisible();
  });
});

認証テスト

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

test.describe('認証', () => {
  test('ログインフローが正常に動作する', async ({ page }) => {
    await page.goto('/login');

    // フォーム入力
    await page.getByLabel('メールアドレス').fill('test@example.com');
    await page.getByLabel('パスワード').fill('password123');

    // ログインボタンをクリック
    await page.getByRole('button', { name: 'ログイン' }).click();

    // ダッシュボードにリダイレクト
    await expect(page).toHaveURL('/dashboard');
    await expect(page.getByText('ようこそ')).toBeVisible();
  });

  test('無効な認証情報でエラーが表示される', async ({ page }) => {
    await page.goto('/login');

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

    // エラーメッセージが表示される
    await expect(page.getByRole('alert')).toContainText(
      'メールアドレスまたはパスワードが正しくありません'
    );
  });
});

// 認証済み状態でのテスト
test.describe('認証済みユーザー', () => {
  test.use({
    storageState: 'e2e/.auth/user.json',
  });

  test('プロフィールページにアクセスできる', async ({ page }) => {
    await page.goto('/profile');

    await expect(page.getByRole('heading', { name: 'プロフィール' })).toBeVisible();
  });
});

認証状態の保存

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

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

setup('authenticate', async ({ page }) => {
  await page.goto('/login');

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

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

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

Page Object Model

// e2e/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;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByLabel('メールアドレス');
    this.passwordInput = page.getByLabel('パスワード');
    this.submitButton = page.getByRole('button', { name: 'ログイン' });
    this.errorMessage = 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();
  }

  async expectError(message: string) {
    await expect(this.errorMessage).toContainText(message);
  }
}

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

export class DashboardPage {
  readonly page: Page;
  readonly welcomeMessage: Locator;
  readonly logoutButton: Locator;

  constructor(page: Page) {
    this.page = page;
    this.welcomeMessage = page.getByText('ようこそ');
    this.logoutButton = page.getByRole('button', { name: 'ログアウト' });
  }

  async expectLoggedIn() {
    await expect(this.welcomeMessage).toBeVisible();
  }

  async logout() {
    await this.logoutButton.click();
  }
}
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
import { DashboardPage } from './pages/DashboardPage';

test.describe('ログイン機能', () => {
  test('正常にログインできる', async ({ page }) => {
    const loginPage = new LoginPage(page);
    const dashboardPage = new DashboardPage(page);

    await loginPage.goto();
    await loginPage.login('test@example.com', 'password123');

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

  test('無効な認証情報でエラーが表示される', async ({ page }) => {
    const loginPage = new LoginPage(page);

    await loginPage.goto();
    await loginPage.login('invalid@example.com', 'wrongpassword');

    await loginPage.expectError('メールアドレスまたはパスワードが正しくありません');
  });
});

APIモック

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

test('APIレスポンスをモックする', async ({ page }) => {
  // API レスポンスをモック
  await page.route('**/api/posts', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, title: 'モック記事1' },
        { id: 2, title: 'モック記事2' },
      ]),
    });
  });

  await page.goto('/posts');

  // モックデータが表示される
  await expect(page.getByText('モック記事1')).toBeVisible();
  await expect(page.getByText('モック記事2')).toBeVisible();
});

test('APIエラーをシミュレートする', async ({ page }) => {
  await page.route('**/api/posts', async (route) => {
    await route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'Internal Server Error' }),
    });
  });

  await page.goto('/posts');

  // エラーメッセージが表示される
  await expect(page.getByText('データの取得に失敗しました')).toBeVisible();
});

Cypressでのテスト

セットアップ

npm install -D cypress
npx cypress open

設定ファイル

// cypress.config.ts
import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    viewportWidth: 1280,
    viewportHeight: 720,
    video: true,
    screenshotOnRunFailure: true,
    setupNodeEvents(on, config) {
      // プラグインの設定
    },
  },
  component: {
    devServer: {
      framework: 'next',
      bundler: 'webpack',
    },
  },
});

基本的なテスト

// cypress/e2e/home.cy.ts
describe('ホームページ', () => {
  beforeEach(() => {
    cy.visit('/');
  });

  it('タイトルが正しく表示される', () => {
    cy.title().should('include', 'Next.js');
    cy.get('h1').should('be.visible');
  });

  it('ナビゲーションが機能する', () => {
    cy.contains('a', '会社概要').click();
    cy.url().should('include', '/about');
    cy.get('h1').should('contain', '会社概要');
  });

  it('検索機能が動作する', () => {
    cy.get('[data-cy=search-input]').type('Next.js{enter}');
    cy.url().should('include', '/search?q=Next.js');
    cy.contains('検索結果').should('be.visible');
  });
});

認証テスト

// cypress/e2e/auth.cy.ts
describe('認証', () => {
  it('ログインフローが正常に動作する', () => {
    cy.visit('/login');

    cy.get('[data-cy=email]').type('test@example.com');
    cy.get('[data-cy=password]').type('password123');
    cy.get('[data-cy=submit]').click();

    cy.url().should('include', '/dashboard');
    cy.contains('ようこそ').should('be.visible');
  });

  it('無効な認証情報でエラーが表示される', () => {
    cy.visit('/login');

    cy.get('[data-cy=email]').type('invalid@example.com');
    cy.get('[data-cy=password]').type('wrongpassword');
    cy.get('[data-cy=submit]').click();

    cy.get('[data-cy=error]').should(
      'contain',
      'メールアドレスまたはパスワードが正しくありません'
    );
  });
});

カスタムコマンド

// cypress/support/commands.ts
Cypress.Commands.add('login', (email: string, password: string) => {
  cy.session([email, password], () => {
    cy.visit('/login');
    cy.get('[data-cy=email]').type(email);
    cy.get('[data-cy=password]').type(password);
    cy.get('[data-cy=submit]').click();
    cy.url().should('include', '/dashboard');
  });
});

// 型定義
declare global {
  namespace Cypress {
    interface Chainable {
      login(email: string, password: string): Chainable<void>;
    }
  }
}
// 使用例
describe('認証済みテスト', () => {
  beforeEach(() => {
    cy.login('test@example.com', 'password123');
  });

  it('プロフィールページにアクセスできる', () => {
    cy.visit('/profile');
    cy.get('h1').should('contain', 'プロフィール');
  });
});

APIインターセプト

// cypress/e2e/posts.cy.ts
describe('記事一覧', () => {
  it('APIレスポンスをインターセプトする', () => {
    cy.intercept('GET', '/api/posts', {
      statusCode: 200,
      body: [
        { id: 1, title: 'モック記事1' },
        { id: 2, title: 'モック記事2' },
      ],
    }).as('getPosts');

    cy.visit('/posts');
    cy.wait('@getPosts');

    cy.contains('モック記事1').should('be.visible');
    cy.contains('モック記事2').should('be.visible');
  });

  it('APIエラーをシミュレートする', () => {
    cy.intercept('GET', '/api/posts', {
      statusCode: 500,
      body: { error: 'Internal Server Error' },
    }).as('getPostsError');

    cy.visit('/posts');
    cy.wait('@getPostsError');

    cy.contains('データの取得に失敗しました').should('be.visible');
  });
});

CI/CD統合

GitHub Actions(Playwright)

# .github/workflows/e2e.yml
name: E2E Tests

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

jobs:
  e2e:
    runs-on: ubuntu-latest

    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: Install Playwright Browsers
        run: npx playwright install --with-deps

      - name: Build Next.js
        run: npm run build

      - name: Run E2E tests
        run: npx playwright test

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

GitHub Actions(Cypress)

# .github/workflows/cypress.yml
name: Cypress Tests

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

jobs:
  cypress:
    runs-on: ubuntu-latest

    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: Cypress run
        uses: cypress-io/github-action@v6
        with:
          build: npm run build
          start: npm start
          wait-on: 'http://localhost:3000'

      - name: Upload screenshots
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: cypress-screenshots
          path: cypress/screenshots

比較表

機能PlaywrightCypress
マルチブラウザ✅ Chromium, Firefox, WebKit✅ Chrome, Firefox, Edge
並列実行✅ ネイティブサポート⚠️ 有料機能
モバイルエミュレーション⚠️ 限定的
ネットワークインターセプト
コンポーネントテスト⚠️ 実験的
デバッグUI✅ UI Mode✅ Test Runner
学習曲線中程度低い

まとめ

E2Eテストのポイントをまとめます。

  • Playwright推奨: マルチブラウザ、並列実行、APIテスト統合
  • Cypress: 学習コストが低く、リアルタイムデバッグが強力
  • Page Object Model: テストの保守性向上
  • CI/CD統合: 自動テストで品質を担保

参考文献

円