Playwright E2Eテスト実践ガイド - モダンなブラウザテスト自動化

intermediate | 60分 で読める | 2025.12.02

公式ドキュメント

Playwrightは、Microsoft製の次世代ブラウザテスト自動化ツールです。Chromium、Firefox、WebKitをサポートし、高速で信頼性の高いE2E(End-to-End)テストを実現します。本記事では、Playwrightの基礎から実践的なテストパターンまで、実際のコードを通じて学びます。

この記事で学ぶこと

  1. Playwrightの環境セットアップ
  2. 基本的なテストの書き方
  3. Page Object Modelの実装
  4. 認証フローのテスト
  5. APIモックとインターセプト
  6. CI/CDへの統合

環境構築

インストール

# 新規プロジェクトの場合
npm init playwright@latest

# 既存プロジェクトへの追加
npm install -D @playwright/test
npx playwright install

Playwright 1.40+ の npm init playwright@latest 実行時に表示される対話型プロンプト例:

? Do you want to use TypeScript or JavaScript? › TypeScript
? Where to put your end-to-end tests? › tests
? Add a GitHub Actions workflow? › true
? Install Playwright browsers? › true

プロジェクト構成

project/
├── playwright.config.ts # 設定ファイル
├── tests/
│ ├── example.spec.ts # テストファイル
│ └── fixtures/ # カスタムフィクスチャ
│ └── auth.ts
├── pages/ # Page Objects
│ ├── login.page.ts
│ └── dashboard.page.ts
├── test-results/ # テスト結果(自動生成)
└── playwright-report/ # HTMLレポート(自動生成)

設定ファイル

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

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

 // 並列実行
 fullyParallel: true,

 // CI環境ではリトライを設定
 retries: process.env.CI ? 2 : 0,

 // ワーカー数
 workers: process.env.CI ? 1 : undefined,

 // レポーター設定
 reporter: [
 ['html', { open: 'never' }],
 ['list'],
 ],

 // 共通設定
 use: {
 // ベースURL
 baseURL: 'http://localhost:3000',

 // スクリーンショット設定
 screenshot: 'only-on-failure',

 // トレース設定
 trace: 'on-first-retry',

 // ビデオ録画
 video: 'on-first-retry',
 },

 // ブラウザ設定
 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 13'] },
 },
 ],

 // 開発サーバーの起動
 webServer: {
 command: 'npm run dev',
 url: 'http://localhost:3000',
 reuseExistingServer: !process.env.CI,
 },
});

基本的なテストの書き方

最初のテスト

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

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

 // タイトルの検証
 await expect(page).toHaveTitle(/My App/);
 });

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

 // リンクをクリック
 await page.getByRole('link', { name: 'About' }).click();

 // URLの検証
 await expect(page).toHaveURL('/about');

 // コンテンツの検証
 await expect(page.getByRole('heading', { level: 1 })).toHaveText('About Us');
 });
});

ロケーター戦略

ポイント: Playwrightでは、堅牢なロケーター(要素の特定方法)を使用することが重要です。ユーザー視点のロケーターを優先し、実装詳細への依存を避けましょう

// 推奨: ユーザー視点のロケーター
page.getByRole('button', { name: '送信' }); // ARIA role
page.getByLabel('メールアドレス'); // ラベル
page.getByPlaceholder('example@email.com'); // プレースホルダー
page.getByText('ログインに成功しました'); // テキスト
page.getByTestId('submit-button'); // data-testid

// 非推奨: 実装詳細に依存するロケーター
page.locator('#submit-btn'); // ID(変更されやすい)
page.locator('.btn-primary'); // クラス(スタイル変更で壊れる)
page.locator('div > button:nth-child(2)'); // 構造依存

アサーション

import { test, expect } from '@playwright/test';

test('各種アサーション', async ({ page }) => {
 await page.goto('/products');

 // 可視性
 await expect(page.getByTestId('loading')).toBeVisible();
 await expect(page.getByTestId('loading')).not.toBeVisible();

 // テキスト内容
 await expect(page.getByRole('heading')).toHaveText('商品一覧');
 await expect(page.getByRole('heading')).toContainText('商品');

 // 属性
 await expect(page.getByRole('link')).toHaveAttribute('href', '/cart');

 // カウント
 await expect(page.getByTestId('product-card')).toHaveCount(10);

 // 有効/無効
 await expect(page.getByRole('button', { name: '購入' })).toBeEnabled();
 await expect(page.getByRole('button', { name: '購入' })).toBeDisabled();

 // フォーム値
 await expect(page.getByLabel('数量')).toHaveValue('1');
});

フォームテスト

入力とサブミット

test.describe('問い合わせフォーム', () => {
 test('フォーム送信が成功する', async ({ page }) => {
 await page.goto('/contact');

 // フォーム入力
 await page.getByLabel('お名前').fill('田中太郎');
 await page.getByLabel('メールアドレス').fill('tanaka@example.com');
 await page.getByLabel('件名').selectOption('inquiry');
 await page.getByLabel('メッセージ').fill('お問い合わせ内容をここに記入します。');

 // チェックボックス
 await page.getByLabel('プライバシーポリシーに同意する').check();

 // 送信
 await page.getByRole('button', { name: '送信' }).click();

 // 成功メッセージの確認
 await expect(page.getByRole('alert')).toHaveText('送信が完了しました');
 });

 test('バリデーションエラーが表示される', async ({ page }) => {
 await page.goto('/contact');

 // 空のまま送信
 await page.getByRole('button', { name: '送信' }).click();

 // エラーメッセージの確認
 await expect(page.getByText('お名前は必須です')).toBeVisible();
 await expect(page.getByText('メールアドレスは必須です')).toBeVisible();
 });
});

Page Object Model

大規模なテストでは、Page Object Modelパターンを使用して保守性を向上させます。

Page Object の作成

// pages/login.page.ts
import { Page, Locator } 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();
 }
}

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

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

 constructor(page: Page) {
 this.page = page;
 this.welcomeMessage = page.getByTestId('welcome-message');
 this.logoutButton = page.getByRole('button', { name: 'ログアウト' });
 this.userMenu = page.getByTestId('user-menu');
 }

 async logout() {
 await this.userMenu.click();
 await this.logoutButton.click();
 }
}

テストでの使用

// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login.page';
import { DashboardPage } from '../pages/dashboard.page';

test.describe('ログイン機能', () => {
 test('正しい認証情報でログインできる', async ({ page }) => {
 const loginPage = new LoginPage(page);
 const dashboardPage = new DashboardPage(page);

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

 // ダッシュボードへのリダイレクトを確認
 await expect(page).toHaveURL('/dashboard');
 await expect(dashboardPage.welcomeMessage).toContainText('ようこそ');
 });

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

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

 await expect(loginPage.errorMessage).toHaveText('メールアドレスまたはパスワードが正しくありません');
 await expect(page).toHaveURL('/login');
 });
});

認証状態の管理

実践メモ: storageStateを使って認証状態を保存・再利用すると、テストごとにログイン処理を繰り返す必要がなくなり、テスト実行が大幅に高速化します

認証状態の保存と再利用

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

const authFile = path.join(__dirname, '../playwright/.auth/user.json');

setup('認証', async ({ page }) => {
 // ログイン処理
 await page.goto('/login');
 await page.getByLabel('メールアドレス').fill('user@example.com');
 await page.getByLabel('パスワード').fill('password123');
 await page.getByRole('button', { name: 'ログイン' }).click();

 // ログイン成功を確認
 await expect(page).toHaveURL('/dashboard');

 // 認証状態を保存
 await page.context().storageState({ path: authFile });
});
// playwright.config.ts
export default defineConfig({
 projects: [
 // 認証セットアップ
 {
 name: 'setup',
 testMatch: /.*\.setup\.ts/,
 },

 // 認証済みテスト
 {
 name: 'chromium',
 use: {
 ...devices['Desktop Chrome'],
 storageState: 'playwright/.auth/user.json',
 },
 dependencies: ['setup'],
 },
 ],
});

カスタムフィクスチャ

// tests/fixtures/auth.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../../pages/login.page';
import { DashboardPage } from '../../pages/dashboard.page';

type AuthFixtures = {
 loginPage: LoginPage;
 dashboardPage: DashboardPage;
 authenticatedPage: DashboardPage;
};

export const test = base.extend<AuthFixtures>({
 loginPage: async ({ page }, use) => {
 const loginPage = new LoginPage(page);
 await use(loginPage);
 },

 dashboardPage: async ({ page }, use) => {
 const dashboardPage = new DashboardPage(page);
 await use(dashboardPage);
 },

 authenticatedPage: async ({ page }, use) => {
 // 自動ログイン
 const loginPage = new LoginPage(page);
 await loginPage.goto();
 await loginPage.login('user@example.com', 'password123');

 const dashboardPage = new DashboardPage(page);
 await use(dashboardPage);
 },
});

export { expect } from '@playwright/test';
// tests/dashboard.spec.ts
import { test, expect } from './fixtures/auth';

test('認証済みユーザーがダッシュボードにアクセスできる', async ({ authenticatedPage }) => {
 await expect(authenticatedPage.welcomeMessage).toBeVisible();
});

APIモックとインターセプト

注意: APIモックに依存しすぎると、実際のバックエンドとの不整合を見逃すリスクがあります。モックテストと実API統合テストを組み合わせましょう。

APIレスポンスのモック

test.describe('商品一覧', () => {
 test('APIからデータを取得して表示する', async ({ page }) => {
 // APIレスポンスをモック
 await page.route('**/api/products', async route => {
 await route.fulfill({
 status: 200,
 contentType: 'application/json',
 body: JSON.stringify([
 { id: 1, name: '商品A', price: 1000 },
 { id: 2, name: '商品B', price: 2000 },
 { id: 3, name: '商品C', price: 3000 },
 ]),
 });
 });

 await page.goto('/products');

 // モックされたデータが表示されることを確認
 await expect(page.getByTestId('product-card')).toHaveCount(3);
 await expect(page.getByText('商品A')).toBeVisible();
 });

 test('APIエラー時にエラーメッセージが表示される', async ({ page }) => {
 await page.route('**/api/products', async route => {
 await route.fulfill({
 status: 500,
 contentType: 'application/json',
 body: JSON.stringify({ error: 'Internal Server Error' }),
 });
 });

 await page.goto('/products');

 await expect(page.getByText('データの取得に失敗しました')).toBeVisible();
 });
});

リクエストの傍受と検証

test('フォーム送信時に正しいデータがAPIに送信される', async ({ page }) => {
 let capturedRequest: any = null;

 // リクエストを傍受
 await page.route('**/api/contact', async route => {
 capturedRequest = route.request().postDataJSON();
 await route.fulfill({ status: 200, body: JSON.stringify({ success: true }) });
 });

 await page.goto('/contact');
 await page.getByLabel('お名前').fill('田中太郎');
 await page.getByLabel('メールアドレス').fill('tanaka@example.com');
 await page.getByRole('button', { name: '送信' }).click();

 // 送信されたデータを検証
 expect(capturedRequest).toEqual({
 name: '田中太郎',
 email: 'tanaka@example.com',
 });
});

ビジュアルリグレッションテスト

ビジュアルリグレッションテストは、UIの意図しない変更をスクリーンショット比較で検出します。

スクリーンショット比較

test('ホームページのスクリーンショット', async ({ page }) => {
 await page.goto('/');

 // ページ全体のスクリーンショット
 await expect(page).toHaveScreenshot('homepage.png');
});

test('コンポーネントのスクリーンショット', async ({ page }) => {
 await page.goto('/components');

 // 特定の要素のみ
 const card = page.getByTestId('feature-card');
 await expect(card).toHaveScreenshot('feature-card.png');
});

test('レスポンシブデザイン', async ({ page }) => {
 await page.goto('/');

 // デスクトップ
 await page.setViewportSize({ width: 1280, height: 720 });
 await expect(page).toHaveScreenshot('homepage-desktop.png');

 // タブレット
 await page.setViewportSize({ width: 768, height: 1024 });
 await expect(page).toHaveScreenshot('homepage-tablet.png');

 // モバイル
 await page.setViewportSize({ width: 375, height: 667 });
 await expect(page).toHaveScreenshot('homepage-mobile.png');
});

CI/CD統合

GitHub Actions設定

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

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

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

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

 - name: Install dependencies
 run: npm ci

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

 - name: Run Playwright tests
 run: npx playwright test

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

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

デバッグテクニック

UI モードでのデバッグ

# UI モードで実行
npx playwright test --ui

# 特定のテストをデバッグ
npx playwright test --debug tests/login.spec.ts

トレースビューア

// playwright.config.ts
use: {
 trace: 'on-first-retry', // 失敗時のみトレース
 // trace: 'on', // 常にトレース
}
# トレースファイルを開く
npx playwright show-trace test-results/example-spec-ts/trace.zip

console.log の表示

test('デバッグ出力', async ({ page }) => {
 // ブラウザのconsole.logをキャプチャ
 page.on('console', msg => console.log('Browser log:', msg.text()));

 await page.goto('/');

 // Playwrightのpause(デバッガ)
 await page.pause();
});

テスト実行コマンド

# 全テスト実行
npx playwright test

# 特定のファイル
npx playwright test tests/login.spec.ts

# 特定のブラウザ
npx playwright test --project=chromium

# ヘッドありモード(ブラウザ表示)
npx playwright test --headed

# 並列数の制御
npx playwright test --workers=4

# 失敗したテストのみ再実行
npx playwright test --last-failed

# タグでフィルタ
npx playwright test --grep @smoke

# HTMLレポートを開く
npx playwright show-report

まとめ

Playwrightを使ったE2Eテストのポイントをまとめます。

ベストプラクティス

  1. ロケーター: ユーザー視点のロケーター(getByRole, getByLabel)を優先
  2. Page Object: 大規模テストでは保守性のためPOMを採用
  3. 認証: storageStateで認証状態を再利用
  4. APIモック: route()でバックエンドに依存しないテスト
  5. CI統合: GitHub Actionsで自動テスト

次のステップ

E2Eテストは開発の品質を大きく向上させます。Playwrightの強力な機能を活用して、信頼性の高いテストを構築しましょう。

参考リンク

参考リソース

← 一覧に戻る
PR
PR
PR
PR