はじめに
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
比較表
| 機能 | Playwright | Cypress |
|---|---|---|
| マルチブラウザ | ✅ Chromium, Firefox, WebKit | ✅ Chrome, Firefox, Edge |
| 並列実行 | ✅ ネイティブサポート | ⚠️ 有料機能 |
| モバイルエミュレーション | ✅ | ⚠️ 限定的 |
| ネットワークインターセプト | ✅ | ✅ |
| コンポーネントテスト | ⚠️ 実験的 | ✅ |
| デバッグUI | ✅ UI Mode | ✅ Test Runner |
| 学習曲線 | 中程度 | 低い |
まとめ
E2Eテストのポイントをまとめます。
- Playwright推奨: マルチブラウザ、並列実行、APIテスト統合
- Cypress: 学習コストが低く、リアルタイムデバッグが強力
- Page Object Model: テストの保守性向上
- CI/CD統合: 自動テストで品質を担保