Playwright完全ガイド2026: E2Eテスト・ブラウザ自動化・CI統合の実践


Playwrightは、Microsoft開発の次世代ブラウザ自動化ツールです。本記事では、E2Eテストの基礎からCI/CD統合、ビジュアルリグレッションテストまで、Playwrightの全機能を実践的に解説します。

Playwrightとは

概要

Playwrightは、Chromium、Firefox、WebKitを統一APIで操作できるブラウザ自動化ツールです。

// 主な特徴
/**
 * 1. マルチブラウザ対応 - Chrome、Firefox、Safari(WebKit)
 * 2. 高速・信頼性 - 自動待機、リトライ機能
 * 3. モダンWeb対応 - SPA、WebSocket、Shadow DOM
 * 4. 強力なデバッグ - タイムトラベルデバッグ、トレース機能
 * 5. 並列実行 - 複数ブラウザ、複数テストの並列実行
 */

Selenium/Puppeteerとの比較

// パフォーマンス比較
/**
 * Selenium:
 * - 古いアーキテクチャ(WebDriver経由)
 * - 待機処理が手動(暗黙的待機)
 * - セットアップが複雑
 *
 * Puppeteer:
 * - Chromeのみ対応
 * - Googleが開発
 * - 高速だが単一ブラウザ
 *
 * Playwright:
 * - 全ブラウザ対応
 * - 自動待機(Auto-wait)
 * - TypeScript完全サポート
 * - テストランナー内蔵
 */

インストールとセットアップ

新規プロジェクト

# npm
npm init playwright@latest

# 対話形式でセットアップ
# → TypeScript / JavaScript
# → テストディレクトリ名
# → GitHub Actionsワークフロー追加
# → ブラウザのインストール

# ブラウザの手動インストール
npx playwright install

# 特定のブラウザのみ
npx playwright install chromium
npx playwright install firefox
npx playwright install webkit

# システム依存関係も含めてインストール(Linux)
npx playwright install --with-deps

既存プロジェクトに追加

# Playwrightのインストール
npm install -D @playwright/test

# ブラウザインストール
npx playwright install

# 設定ファイル生成
npm init playwright@latest

設定ファイル

// playwright.config.ts

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

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

  // タイムアウト設定
  timeout: 30000, // 各テスト30秒
  expect: {
    timeout: 5000, // expect の待機時間
  },

  // 並列実行設定
  fullyParallel: true, // すべてのテストを並列実行
  workers: process.env.CI ? 1 : undefined, // CI環境では1ワーカー

  // 失敗時のリトライ
  retries: process.env.CI ? 2 : 0,

  // レポーター
  reporter: [
    ['html'], // HTMLレポート
    ['json', { outputFile: 'test-results.json' }],
    ['junit', { outputFile: 'test-results.xml' }],
  ],

  // グローバル設定
  use: {
    // ベースURL
    baseURL: 'http://localhost:3000',

    // トレース記録(失敗時のみ)
    trace: 'on-first-retry',

    // スクリーンショット(失敗時のみ)
    screenshot: 'only-on-failure',

    // ビデオ録画
    video: 'retain-on-failure',

    // ヘッドレスモード
    headless: true,

    // ビューポート
    viewport: { width: 1280, height: 720 },

    // タイムアウト
    actionTimeout: 10000,
    navigationTimeout: 30000,
  },

  // ブラウザ設定
  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,
    timeout: 120000,
  },
})

基本的なテスト

最初のテスト

// tests/example.spec.ts

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

test('basic test', async ({ page }) => {
  // ページに移動
  await page.goto('https://playwright.dev/')

  // タイトル確認
  await expect(page).toHaveTitle(/Playwright/)

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

  // URLの確認
  await expect(page).toHaveURL(/.*intro/)
})

test('search functionality', async ({ page }) => {
  await page.goto('https://example.com')

  // 検索ボックスに入力
  await page.getByPlaceholder('Search...').fill('playwright')

  // 検索ボタンをクリック
  await page.getByRole('button', { name: 'Search' }).click()

  // 結果が表示されるまで待機
  await expect(page.getByText('Search Results')).toBeVisible()

  // 結果の検証
  const results = page.locator('.search-result')
  await expect(results).toHaveCount(10)
})
# テスト実行
npx playwright test

# 特定のファイルのみ
npx playwright test example.spec.ts

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

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

# デバッグモード
npx playwright test --debug

# UIモード(インタラクティブ)
npx playwright test --ui

セレクター

// tests/selectors.spec.ts

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

test('various selectors', async ({ page }) => {
  await page.goto('https://example.com')

  // テキストで検索
  await page.getByText('Submit').click()

  // ロール(アクセシビリティ)で検索
  await page.getByRole('button', { name: 'Submit' }).click()
  await page.getByRole('textbox', { name: 'Email' }).fill('test@example.com')
  await page.getByRole('link', { name: 'Read more' }).click()

  // ラベルで検索
  await page.getByLabel('Username').fill('john')
  await page.getByLabel('Password').fill('secret')

  // プレースホルダーで検索
  await page.getByPlaceholder('Enter your email').fill('test@example.com')

  // テストIDで検索(推奨)
  await page.getByTestId('submit-button').click()

  // CSSセレクター
  await page.locator('.submit-btn').click()
  await page.locator('#username').fill('john')

  // XPath
  await page.locator('xpath=//button[@type="submit"]').click()

  // 複合条件
  await page.locator('button:has-text("Submit")').click()

  // 親要素から検索
  const form = page.locator('form')
  await form.locator('input[name="email"]').fill('test@example.com')

  // n番目の要素
  await page.locator('button').nth(2).click()
  await page.locator('button').first().click()
  await page.locator('button').last().click()

  // フィルタリング
  await page.locator('button').filter({ hasText: 'Submit' }).click()
  await page.locator('article').filter({ has: page.locator('h2') }).first()
})

アサーション

// tests/assertions.spec.ts

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

test('various assertions', async ({ page }) => {
  await page.goto('https://example.com')

  // ページタイトル
  await expect(page).toHaveTitle('Example Domain')
  await expect(page).toHaveTitle(/Example/)

  // URL
  await expect(page).toHaveURL('https://example.com/')
  await expect(page).toHaveURL(/example/)

  // 要素の表示
  await expect(page.getByText('Example Domain')).toBeVisible()
  await expect(page.getByText('Hidden Text')).toBeHidden()

  // 要素の有効/無効
  await expect(page.getByRole('button')).toBeEnabled()
  await expect(page.getByRole('button')).toBeDisabled()

  // テキスト内容
  await expect(page.locator('h1')).toHaveText('Example Domain')
  await expect(page.locator('h1')).toContainText('Example')

  // 属性
  await expect(page.locator('input')).toHaveAttribute('type', 'text')
  await expect(page.locator('input')).toHaveAttribute('placeholder', /Enter/)

  // 値
  await expect(page.locator('input')).toHaveValue('default value')

  // カウント
  await expect(page.locator('li')).toHaveCount(5)

  // クラス
  await expect(page.locator('button')).toHaveClass('btn-primary')
  await expect(page.locator('button')).toHaveClass(/btn-/)

  // CSS
  await expect(page.locator('h1')).toHaveCSS('color', 'rgb(0, 0, 0)')

  // スクリーンショット(ビジュアルリグレッション)
  await expect(page).toHaveScreenshot('homepage.png')
  await expect(page.locator('.hero')).toHaveScreenshot('hero-section.png')
})

ページオブジェクトモデル

基本的なページオブジェクト

// pages/login.page.ts

import { Page, Locator } from '@playwright/test'

export class LoginPage {
  readonly page: Page
  readonly usernameInput: Locator
  readonly passwordInput: Locator
  readonly submitButton: Locator
  readonly errorMessage: Locator

  constructor(page: Page) {
    this.page = page
    this.usernameInput = page.getByLabel('Username')
    this.passwordInput = page.getByLabel('Password')
    this.submitButton = page.getByRole('button', { name: 'Sign in' })
    this.errorMessage = page.getByTestId('error-message')
  }

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

  async login(username: string, password: string) {
    await this.usernameInput.fill(username)
    await this.passwordInput.fill(password)
    await this.submitButton.click()
  }

  async getErrorMessage() {
    return await this.errorMessage.textContent()
  }
}
// tests/login.spec.ts

import { test, expect } from '@playwright/test'
import { LoginPage } from '../pages/login.page'

test('successful login', async ({ page }) => {
  const loginPage = new LoginPage(page)

  await loginPage.goto()
  await loginPage.login('testuser', 'password123')

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

test('login with invalid credentials', async ({ page }) => {
  const loginPage = new LoginPage(page)

  await loginPage.goto()
  await loginPage.login('invalid', 'wrong')

  const error = await loginPage.getErrorMessage()
  expect(error).toContain('Invalid credentials')
})

再利用可能なコンポーネント

// components/navbar.component.ts

import { Page, Locator } from '@playwright/test'

export class NavbarComponent {
  readonly page: Page
  readonly homeLink: Locator
  readonly profileLink: Locator
  readonly logoutButton: Locator
  readonly searchInput: Locator

  constructor(page: Page) {
    this.page = page
    const navbar = page.locator('nav[data-testid="navbar"]')
    this.homeLink = navbar.getByRole('link', { name: 'Home' })
    this.profileLink = navbar.getByRole('link', { name: 'Profile' })
    this.logoutButton = navbar.getByRole('button', { name: 'Logout' })
    this.searchInput = navbar.getByPlaceholder('Search...')
  }

  async goToHome() {
    await this.homeLink.click()
  }

  async goToProfile() {
    await this.profileLink.click()
  }

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

  async search(query: string) {
    await this.searchInput.fill(query)
    await this.searchInput.press('Enter')
  }
}

継承を使ったページオブジェクト

// pages/base.page.ts

import { Page } from '@playwright/test'
import { NavbarComponent } from '../components/navbar.component'

export class BasePage {
  readonly page: Page
  readonly navbar: NavbarComponent

  constructor(page: Page) {
    this.page = page
    this.navbar = new NavbarComponent(page)
  }

  async goto(path: string) {
    await this.page.goto(path)
  }

  async waitForPageLoad() {
    await this.page.waitForLoadState('networkidle')
  }
}
// pages/dashboard.page.ts

import { BasePage } from './base.page'
import { Page, Locator } from '@playwright/test'

export class DashboardPage extends BasePage {
  readonly welcomeMessage: Locator
  readonly statsCards: Locator

  constructor(page: Page) {
    super(page)
    this.welcomeMessage = page.getByTestId('welcome-message')
    this.statsCards = page.locator('.stat-card')
  }

  async goto() {
    await super.goto('/dashboard')
  }

  async getWelcomeMessage() {
    return await this.welcomeMessage.textContent()
  }

  async getStatsCount() {
    return await this.statsCards.count()
  }
}

高度なテクニック

ネットワークリクエストのモック

// tests/mock-api.spec.ts

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

test('mock API response', async ({ page }) => {
  // APIレスポンスをモック
  await page.route('**/api/users', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, name: 'Alice' },
        { id: 2, name: 'Bob' },
      ]),
    })
  })

  await page.goto('/users')

  // モックデータが表示されることを確認
  await expect(page.getByText('Alice')).toBeVisible()
  await expect(page.getByText('Bob')).toBeVisible()
})

test('mock slow API', async ({ page }) => {
  // 遅延を追加
  await page.route('**/api/data', async (route) => {
    await new Promise((resolve) => setTimeout(resolve, 3000))
    await route.fulfill({
      status: 200,
      body: JSON.stringify({ data: 'slow response' }),
    })
  })

  await page.goto('/data')

  // ローディング表示の確認
  await expect(page.getByText('Loading...')).toBeVisible()

  // データ表示の確認
  await expect(page.getByText('slow response')).toBeVisible()
})

test('mock API error', async ({ page }) => {
  // エラーレスポンス
  await page.route('**/api/users', async (route) => {
    await route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'Internal Server Error' }),
    })
  })

  await page.goto('/users')

  // エラーメッセージの確認
  await expect(page.getByText('Failed to load users')).toBeVisible()
})

リクエストのインターセプト

// tests/intercept.spec.ts

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

test('modify request', async ({ page }) => {
  // リクエストを変更
  await page.route('**/api/users', async (route) => {
    const request = route.request()

    // ヘッダーを追加
    await route.continue({
      headers: {
        ...request.headers(),
        'X-Custom-Header': 'test-value',
      },
    })
  })

  await page.goto('/users')
})

test('abort requests', async ({ page }) => {
  // 画像とCSSをブロック(高速化)
  await page.route('**/*.{png,jpg,jpeg,css}', (route) => route.abort())

  await page.goto('/')
})

test('capture API response', async ({ page }) => {
  // レスポンスをキャプチャ
  const responsePromise = page.waitForResponse('**/api/users')

  await page.goto('/users')

  const response = await responsePromise
  const data = await response.json()

  expect(data).toHaveLength(10)
})

ファイルアップロード/ダウンロード

// tests/file-operations.spec.ts

import { test, expect } from '@playwright/test'
import path from 'path'
import fs from 'fs'

test('file upload', async ({ page }) => {
  await page.goto('/upload')

  // ファイル選択
  const fileInput = page.locator('input[type="file"]')
  await fileInput.setInputFiles(path.join(__dirname, 'fixtures/sample.pdf'))

  // アップロードボタン
  await page.getByRole('button', { name: 'Upload' }).click()

  // 成功メッセージ
  await expect(page.getByText('File uploaded successfully')).toBeVisible()
})

test('multiple file upload', async ({ page }) => {
  await page.goto('/upload')

  const fileInput = page.locator('input[type="file"]')
  await fileInput.setInputFiles([
    path.join(__dirname, 'fixtures/file1.pdf'),
    path.join(__dirname, 'fixtures/file2.pdf'),
  ])

  await page.getByRole('button', { name: 'Upload' }).click()
})

test('file download', async ({ page }) => {
  await page.goto('/downloads')

  // ダウンロード待機
  const downloadPromise = page.waitForEvent('download')

  await page.getByRole('link', { name: 'Download PDF' }).click()

  const download = await downloadPromise

  // ファイル名確認
  expect(download.suggestedFilename()).toBe('document.pdf')

  // ファイルを保存
  const filePath = path.join(__dirname, 'downloads', download.suggestedFilename())
  await download.saveAs(filePath)

  // ファイルが存在することを確認
  expect(fs.existsSync(filePath)).toBeTruthy()
})

認証の保存/復元

// tests/auth.setup.ts

import { test as setup } from '@playwright/test'

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

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

  await page.getByLabel('Username').fill('testuser')
  await page.getByLabel('Password').fill('password123')
  await page.getByRole('button', { name: 'Sign in' }).click()

  await page.waitForURL('/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: authFile,
      },
      dependencies: ['setup'],
    },
  ],
})
// tests/authenticated.spec.ts

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

// 認証状態が自動的に復元される
test('access dashboard', async ({ page }) => {
  await page.goto('/dashboard')

  await expect(page.getByText('Welcome back')).toBeVisible()
})

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

スクリーンショット比較

// tests/visual.spec.ts

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

test('homepage visual test', async ({ page }) => {
  await page.goto('/')

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

test('component visual test', async ({ page }) => {
  await page.goto('/components')

  // 特定要素のスクリーンショット
  await expect(page.locator('.hero-section')).toHaveScreenshot('hero.png')
  await expect(page.locator('.features')).toHaveScreenshot('features.png')
})

test('visual test with options', async ({ page }) => {
  await page.goto('/')

  // オプション指定
  await expect(page).toHaveScreenshot('homepage-custom.png', {
    // 差分の許容範囲
    maxDiffPixels: 100,

    // 特定要素をマスク
    mask: [page.locator('.dynamic-content')],

    // アニメーションを無効化
    animations: 'disabled',

    // フルページスクリーンショット
    fullPage: true,
  })
})
# スクリーンショット生成(初回)
npx playwright test --update-snapshots

# ビジュアルテスト実行
npx playwright test visual.spec.ts

# 差分が出た場合、差分画像が生成される
# - homepage-actual.png
# - homepage-expected.png
# - homepage-diff.png

複数デバイスでのビジュアルテスト

// tests/responsive-visual.spec.ts

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

const viewports = [
  { name: 'Desktop', ...devices['Desktop Chrome'] },
  { name: 'Tablet', ...devices['iPad Pro'] },
  { name: 'Mobile', ...devices['iPhone 13'] },
]

for (const viewport of viewports) {
  test(`visual test on ${viewport.name}`, async ({ browser }) => {
    const context = await browser.newContext(viewport)
    const page = await context.newPage()

    await page.goto('/')

    await expect(page).toHaveScreenshot(`homepage-${viewport.name}.png`)

    await context.close()
  })
}

CI/CD統合

GitHub Actions

# .github/workflows/playwright.yml

name: Playwright Tests

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

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

    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 Playwright Report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

並列実行(シャーディング)

# .github/workflows/playwright-parallel.yml

name: Playwright Tests (Parallel)

on: push

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4

      - name: Install dependencies
        run: npm ci

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

      - name: Run Playwright tests
        run: npx playwright test --shard=${{ matrix.shard }}/4

      - name: Upload blob report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: blob-report-${{ matrix.shard }}
          path: blob-report/
          retention-days: 1

  merge-reports:
    if: always()
    needs: [test]
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4

      - name: Download blob reports
        uses: actions/download-artifact@v4
        with:
          path: all-blob-reports
          pattern: blob-report-*
          merge-multiple: true

      - name: Merge reports
        run: npx playwright merge-reports --reporter html ./all-blob-reports

      - name: Upload HTML report
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/

Docker

# Dockerfile

FROM mcr.microsoft.com/playwright:v1.42.0-jammy

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .

RUN npx playwright install

CMD ["npx", "playwright", "test"]
# docker-compose.yml

version: '3.8'

services:
  playwright:
    build: .
    volumes:
      - ./tests:/app/tests
      - ./playwright-report:/app/playwright-report
    environment:
      - CI=true

デバッグとトラブルシューティング

デバッグモード

# Playwright Inspector(ステップ実行)
npx playwright test --debug

# 特定のテストのみデバッグ
npx playwright test example.spec.ts:10 --debug

# ブラウザを表示
npx playwright test --headed

# スローモーション
npx playwright test --headed --slow-mo=1000

トレース

// playwright.config.ts

export default defineConfig({
  use: {
    trace: 'on-first-retry', // 失敗時のみトレース
    // trace: 'on', // すべてのテストでトレース
  },
})
# トレースビューアー
npx playwright show-trace test-results/example-chromium/trace.zip

コードジェネレーター

# コードジェネレーター起動
npx playwright codegen https://example.com

# 特定デバイスでコード生成
npx playwright codegen --device="iPhone 13" https://example.com

# 認証付きでコード生成
npx playwright codegen --load-storage=auth.json https://example.com

よくある問題

// tests/troubleshooting.spec.ts

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

test('wait for element', async ({ page }) => {
  await page.goto('/')

  // 要素が表示されるまで待機
  await page.waitForSelector('.dynamic-element')

  // ネットワークが安定するまで待機
  await page.waitForLoadState('networkidle')

  // 特定のURLまで待機
  await page.waitForURL('**/dashboard')

  // カスタム条件で待機
  await page.waitForFunction(() => {
    return document.querySelectorAll('.item').length > 10
  })
})

test('handle timeouts', async ({ page }) => {
  // タイムアウト延長
  await page.goto('/', { timeout: 60000 })

  // 個別のアクションでタイムアウト指定
  await page.locator('button').click({ timeout: 5000 })
})

test('retry assertions', async ({ page }) => {
  await page.goto('/')

  // 自動リトライ(最大5秒)
  await expect(page.locator('.dynamic-text')).toHaveText('Expected Text', {
    timeout: 5000,
  })
})

まとめ

Playwrightは、モダンなブラウザ自動化とE2Eテストのための強力なツールです。

主な利点

  • マルチブラウザ対応(Chrome、Firefox、Safari)
  • 高速・信頼性の高い自動待機
  • TypeScript完全サポート
  • 強力なデバッグ機能(トレース、タイムトラベル)
  • CI/CD統合が容易

適用場面

  • E2Eテスト自動化
  • ブラウザ自動化スクリプト
  • ビジュアルリグレッションテスト
  • パフォーマンステスト

ベストプラクティス

  • ページオブジェクトモデルを使用
  • テストIDを使ったセレクター
  • 認証状態の再利用
  • 並列実行でテスト高速化

2026年現在、Playwrightは急速に普及しており、多くのプロジェクトでSeleniumからの移行が進んでいます。

参考リンク