この記事の要点
• Chromium/Firefox/WebKitを統一APIで操作可能な最新E2Eテストフレームワーク
• 自動待機(auto-waiting)とTrace Viewerでflakyテストの削減とデバッグが劇的に改善
• API Testing、Component Testing、視覚回帰テストまで一つのツールでカバー
Playwright は Microsoft が開発する、Chromium / Firefox / WebKit すべてをサポートする E2E テスト自動化フレームワークです。2025 年現在、Cypress や Selenium を上回る勢いで採用が広がり、モダン Web 開発のデファクトスタンダードになりつつあります。本記事では 2025 年時点の Playwright の主要機能と実践的な使い方をまとめます。
Playwrightの概要
アーキテクチャ
flowchart TB
Test["Test file (TS)"] --> Runner["@playwright/test runner"]
Runner --> Fixture["Fixture / Worker"]
Fixture --> Browser["Browser (Chromium/Firefox/WebKit)"]
Browser --> Page["Page / BrowserContext"]
Page --> Locator["Locator API"]
Page --> Trace["Trace Viewer"]
Page --> Screenshot["Screenshot / Video"]
なぜ Playwright か
- 3 つのブラウザエンジンを統一 API で操作可能
- 自動待機 (auto-waiting) による flaky テストの削減
- Trace Viewer によるデバッグ体験
- 並列実行とシャーディング
- Component Testing / API Testing の組み込み
- TypeScript ファーストな API
主要機能詳細
1. Locator API
Playwright の中心的な API で、ユーザ視点のセレクタを推奨します。
import { test, expect } from '@playwright/test';
test('login flow', async ({ page }) => {
await page.goto('https://example.com/login');
await page.getByLabel('Email').fill('alice@example.com');
await page.getByLabel('Password').fill('secret');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
推奨順:
getByRole- アクセシビリティツリー経由getByLabel/getByPlaceholdergetByTextgetByTestId- 他が使えないとき
2. 自動待機
// 要素がvisible & enabled & stable になるまで自動で待機
await page.getByRole('button', { name: 'Submit' }).click();
// expect もretry付き
await expect(page.getByText('Saved')).toBeVisible({ timeout: 5000 });
3. Trace Viewer
# 実行時にトレースを保存
npx playwright test --trace on
# トレースを開く
npx playwright show-trace trace.zip
ポイント: Trace ViewerではDOMスナップショット、ネットワーク、コンソール、アクションログが時系列で参照でき、デバッグが劇的に楽になります。
Trace Viewer では DOM スナップショット、ネットワーク、コンソール、アクションログが時系列で参照でき、失敗したテストのデバッグが劇的に楽になります。
4. API Testing
import { test, expect } from '@playwright/test';
test('create user via API', async ({ request }) => {
const res = await request.post('/api/users', {
data: { name: 'Bob', email: 'bob@example.com' },
});
expect(res.ok()).toBeTruthy();
const body = await res.json();
expect(body).toMatchObject({ name: 'Bob' });
});
5. Component Testing
// Button.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { Button } from './Button';
test('button renders and clicks', async ({ mount }) => {
let clicked = false;
const component = await mount(
<Button onClick={() => { clicked = true; }}>Save</Button>,
);
await expect(component).toContainText('Save');
await component.click();
expect(clicked).toBe(true);
});
実践サンプル
Page Object Model
// pages/LoginPage.ts
import type { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
}
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();
}
}
// tests/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
test.describe('auth', () => {
test('successful login', async ({ page }) => {
const login = new LoginPage(page);
await login.goto();
await login.login('alice@example.com', 'secret');
await expect(page).toHaveURL(/\/dashboard/);
});
});
認証状態の再利用
// 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('Email').fill(process.env.TEST_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: authFile });
});
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
],
});
視覚回帰テスト
import { test, expect } from '@playwright/test';
test('homepage visual', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('homepage.png', {
fullPage: true,
maxDiffPixelRatio: 0.01,
});
});
test('hero component', async ({ page }) => {
await page.goto('/');
const hero = page.getByTestId('hero');
await expect(hero).toHaveScreenshot('hero.png');
});
ネットワークモック
test('mock API', async ({ page }) => {
await page.route('**/api/users', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 1, name: 'Mock User' }]),
});
});
await page.goto('/users');
await expect(page.getByText('Mock User')).toBeVisible();
});
CI 設定 (GitHub Actions)
name: Playwright Tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install deps
run: pnpm install --frozen-lockfile
- name: Install browsers
run: pnpm exec playwright install --with-deps
- name: Run tests
run: pnpm exec playwright test --shard=${{ matrix.shard }}/4
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report-${{ matrix.shard }}
path: playwright-report/
retention-days: 7
比較表
E2E テストツール比較
| 項目 | Playwright | Cypress | Selenium |
|---|---|---|---|
| 対応ブラウザ | Chromium/Firefox/WebKit | Chromium系中心 | ほぼ全て |
| 多タブ/複数オリジン | ネイティブ対応 | 制限あり | 対応 |
| 自動待機 | あり | あり | なし |
| API テスト | あり | あり (plugin) | なし |
| Component Test | あり (experimental) | あり | なし |
| 並列実行 | ネイティブ | 有料版 | 外部ツール |
| デバッグツール | Trace Viewer | Dashboard | なし |
ベストプラクティス
1. ユーザ視点のセレクタ
CSS セレクタや XPath ではなく getByRole を優先します。アクセシビリティも同時に検証できます。
2. テストを独立させる
各テストは他のテストに依存せず、独立してパスするように書きます。test.describe.serial は最後の手段です。
3. test.step で可読性を上げる
test('checkout', async ({ page }) => {
await test.step('add item', async () => {
await page.getByRole('button', { name: 'Add to cart' }).click();
});
await test.step('go to cart', async () => {
await page.getByRole('link', { name: 'Cart' }).click();
});
});
4. 環境変数で環境分離
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
}
5. Fixtures を活用する
繰り返されるセットアップは custom fixture にまとめます。
import { test as base } from '@playwright/test';
type MyFixtures = {
authedPage: Page;
};
export const test = base.extend<MyFixtures>({
authedPage: async ({ page }, use) => {
await page.goto('/login');
// ログイン処理
await use(page);
},
});
注意点
注意: 視覚回帰テストはOS/フォント/GPUの違いで差分が出やすいため、CI上で生成したベースライン画像をコミットするのが基本です。
- 視覚回帰テストは OS / フォント / GPU の違いで差分が出やすいため、CI 上で生成したベースライン画像をコミットするのが基本です。
page.waitForTimeoutは原則使わず、expectのリトライかwaitForを使います。- 実行時のブラウザバイナリは
playwright installで別途取得する必要があります。CI では毎回インストールするかキャッシュを検討しましょう。 - WebKit は macOS 以外では Linux ビルドが使われるため、Safari 実機と微妙に差が出る場合があります。
- 大量のテストを並列実行するとマシンリソースを食い尽くすので、
workersとシャードを適切に設定します。
導入手順
# 新規導入
pnpm create playwright
# 既存プロジェクトに追加
pnpm add -D @playwright/test
pnpm exec playwright install
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: [['html'], ['list']],
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'] } },
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
パフォーマンス
ワーカー数とシャード
# ローカル: CPUコア数の半分
npx playwright test --workers=4
# CI: シャードで物理マシン分割
npx playwright test --shard=1/4
storageState によるログイン短縮
実践メモ: ログインはsetupプロジェクトで一度だけ実行し、storageStateで再利用するとテスト実行時間を大幅に削減できます。
ログインは setup プロジェクトで一度だけ実行し、各テストは保存済みの状態を再利用することで秒単位で実行時間を削減できます。
テスト隔離と再利用のバランス
test.describe.parallel でテスト内のブロックを並列化、test.describe.configure({ mode: 'serial' }) で直列化を選べます。
FAQ
Q: Cypress から移行する価値はありますか? A: クロスブラウザや多タブ、API テストの要件があれば価値は大きいです。既存プロジェクトが安定しているなら急ぐ必要はありません。
Q: テストが flaky です、どうすれば?
A: waitForTimeout を expect(locator).toBeVisible() に置き換え、Trace Viewer で原因を特定します。
Q: モバイルブラウザのテストは?
A: devices['iPhone 14'] などでエミュレーションできますが、実機は別途クラウドサービスを使うのが一般的です。
Q: 認証情報をどう扱う?
A: .env.test と GitHub Secrets を使い、テスト用の専用アカウントを用意します。
Q: 1 テストあたりの理想的な時間は? A: 10 秒以内を目安にし、それ以上は並列化やセットアップの見直しを検討します。
まとめ
Playwright はモダン E2E テストの決定版と言える完成度を持ち、クロスブラウザ対応、Trace Viewer による強力なデバッグ体験、API / Component テストまで一つのツールでカバーできます。自動待機による flaky テストの削減、Page Object Model、Fixture、ネットワークモックといった機能を組み合わせれば、保守性の高いテストスイートを構築できます。E2E テスト基盤を新しく立ち上げるなら、まず Playwright を検討することを強く推奨します。