TL;DR
- 技術スタック: Playwright + TypeScript、Page Object Model + Fixtures パターン
- 解決する課題: E2Eテストのコード重複、保守性の低下、実行時間の増大
- 主要テクニック:
- 型安全なカスタムFixture設計でテストコードの再利用性向上
- 3層アーキテクチャによるPage Object階層化で変更容易性を確保
storageStateによる認証状態の再利用でテスト時間を最大80%短縮
- 得られる成果: 100件のテストで約8分の時間短縮、UIロジック変更時の影響範囲を限定
はじめに
「E2Eテストのコードが肥大化してきて、同じようなログイン処理をあちこちに書いている...」 「テストが増えるたびにPage Objectのインスタンス化を毎回書くのが面倒」 「認証が必要なテストを毎回ログインから始めるので、テスト実行に時間がかかりすぎる」
こんな悩みを抱えていませんか?
E2Eテストは、アプリケーションの品質を保証する上で欠かせない存在です。しかし、テストコードが増えるにつれて、保守性の問題や実行時間の増大といった課題が顕在化してきます。
本記事では、PlaywrightのFixtures機能とPage Objectパターンを組み合わせることで、これらの課題を解決するアプローチを紹介します。実際のプロジェクトで培ったノウハウをもとに、スケーラブルなE2Eテスト設計の実践方法を解説していきます。
全体アーキテクチャ
まず、本記事で紹介する設計パターンの全体像を把握しておきましょう。

このアーキテクチャにより、テストコードはシンプルに保ちつつ、再利用性と保守性を両立できます。
Page ObjectパターンとFixturesの基礎
Page Objectパターンとは
Page Objectパターンは、Webページの構造と操作をクラスとしてカプセル化する設計パターンです。これにより、UIの変更が発生した際の影響範囲を限定し、テストコードの保守性を向上させます。
import { Page, Locator, expect } from '@playwright/test';
export class LoginPage {
private readonly page: Page;
private readonly emailInput: Locator;
private readonly passwordInput: Locator;
private readonly loginButton: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByRole('textbox', { name: 'メールアドレス' });
this.passwordInput = page.getByRole('textbox', { name: 'パスワード' });
this.loginButton = page.getByRole('button', { name: 'ログイン' });
}
async goto(): Promise<void> {
await this.page.goto('/login');
}
async login(email: string, password: string): Promise<void> {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.loginButton.click();
}
async isLoginFormVisible(): Promise<boolean> {
return await this.loginButton.isVisible();
}
}Page Objectの主なメリットは以下の通りです:
- セレクタの一元管理: UI要素の変更時に修正箇所が限定される
- 再利用可能な操作: 複数のテストで同じ操作を共有できる
- 可読性の向上: テストコードがビジネスロジックに集中できる
Fixturesとは
Playwrightのfixturesは、テストに必要なリソースのセットアップとティアダウンを管理する仕組みです。依存性注入(DI)パターンを採用しており、テストコードをクリーンに保つことができます。
import { test as base } from '@playwright/test';
import { LoginPage } from '../page-objects/login-page';
// カスタムfixtureの型定義
interface MyFixtures {
loginPage: LoginPage;
}
// test.extend()でカスタムfixtureを追加
export const test = base.extend<MyFixtures>({
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await use(loginPage);
},
});
export { expect } from '@playwright/test';fixturesの主な特徴は以下の通りです:
- オンデマンド実行: テストで使用されるfixtureのみがセットアップされる
- 自動的なティアダウン:
use()の後に記述したコードが自動実行される - 依存関係の自動解決: fixture間の依存関係を自動的に解決する
実践的なFixtures設計パターン
パターン1: 基本的なPage Object Fixture
最もシンプルなパターンは、Page Objectをfixtureとして定義することです。
import { test as base } from '@playwright/test';
import { LoginPage } from '../page-objects/login-page';
import { DashboardPage } from '../page-objects/dashboard-page';
import { SettingsPage } from '../page-objects/settings-page';
interface AppFixtures {
loginPage: LoginPage;
dashboardPage: DashboardPage;
settingsPage: SettingsPage;
}
export const test = base.extend<AppFixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
settingsPage: async ({ page }, use) => {
await use(new SettingsPage(page));
},
});
export { expect } from '@playwright/test';テストコードでは、必要なfixtureを引数として受け取るだけで使用できます:
import { test, expect } from '../fixtures';
test('ログイン後にダッシュボードが表示される', async ({ loginPage, dashboardPage }) => {
await loginPage.goto();
await loginPage.login('user@example.com', 'password123');
expect(await dashboardPage.isVisible()).toBeTruthy();
});パターン2: 設定オプション付きFixture
テスト環境の設定をfixtureとして注入することで、柔軟なテスト実行が可能になります。
import { test as base } from '@playwright/test';
interface TestOptions {
baseUrl: string;
defaultTimeout: number;
}
interface TestFixtures {
testConfig: TestOptions;
}
export const test = base.extend<TestFixtures & TestOptions>({
// オプションとしてデフォルト値を定義
baseUrl: ['http://localhost:3000', { option: true }],
defaultTimeout: [30000, { option: true }],
// 設定オブジェクトを提供するfixture
testConfig: async ({ baseUrl, defaultTimeout }, use) => {
await use({ baseUrl, defaultTimeout });
},
});テストファイルやプロジェクト設定でオプションを上書きできます:
// 特定のテストファイルで設定を上書き
test.use({
baseUrl: 'https://staging.example.com',
defaultTimeout: 60000
});パターン3: Worker-scopedな共有リソース
重い初期化処理が必要なリソースは、worker-scopedなfixtureとして定義することで、ワーカープロセス内で共有できます。
用語解説: worker-scopedとは、Playwrightの並列実行単位である「ワーカープロセス」内でリソースを共有するスコープです。通常のtest-scopedでは各テストごとに初期化されますが、worker-scopedでは同じワーカー内の複数テストで同じインスタンスを再利用できます。
import { test as base } from '@playwright/test';
interface WorkerFixtures {
apiClient: ApiClient;
}
export const test = base.extend<{}, WorkerFixtures>({
apiClient: [async ({}, use) => {
// 重い初期化処理(ワーカーごとに1回だけ実行)
const client = await ApiClient.initialize();
await use(client);
// クリーンアップ(ワーカー終了時に実行)
await client.dispose();
}, { scope: 'worker' }],
});階層化されたPage Object設計
大規模なプロジェクトでは、Page Objectを階層化することで、コードの再利用性と保守性を高めることができます。
3層アーキテクチャの実装
// レイヤー1: 汎用的な基底クラス
export class BasePage {
constructor(protected page: Page) {}
async goto(path: string): Promise<void> {
await this.page.goto(path);
}
async waitForLoad(): Promise<void> {
await this.page.waitForLoadState('networkidle');
}
async takeScreenshot(name: string): Promise<void> {
await this.page.screenshot({
path: `screenshots/${name}.png`,
fullPage: true
});
}
}
// レイヤー2: アプリケーション共通の基底クラス
export class AppBasePage extends BasePage {
async logout(): Promise<void> {
await this.page.getByRole('button', { name: 'ログアウト' }).click();
await this.page.waitForURL('/login');
}
async getToastMessage(): Promise<string | null> {
const toast = this.page.locator('[data-testid="toast-message"]');
if (await toast.isVisible()) {
return await toast.textContent();
}
return null;
}
}
// レイヤー3: 画面固有のページクラス
export class UserSettingsPage extends AppBasePage {
private readonly nameInput: Locator;
private readonly saveButton: Locator;
constructor(page: Page) {
super(page);
this.nameInput = page.getByRole('textbox', { name: '表示名' });
this.saveButton = page.getByRole('button', { name: '保存' });
}
async updateDisplayName(name: string): Promise<void> {
await this.nameInput.fill(name);
await this.saveButton.click();
}
}この3層構造により、以下のメリットが得られます:
| レイヤー | 責務 | 変更頻度 |
|---|---|---|
| BasePage | ブラウザ操作の抽象化 | 低 |
| AppBasePage | アプリ共通の操作 | 中 |
| FeaturePage | 画面固有のロジック | 高 |
認証状態の効率的な管理
E2Eテストで最も時間がかかる処理の一つが認証です。PlaywrightのstorageState機能を活用することで、認証状態を再利用し、テスト実行時間を大幅に短縮できます。
用語解説:
storageStateとは、Cookie、LocalStorage、SessionStorageなどのブラウザストレージ状態をJSONファイルとして保存・復元する機能です。一度ログインした状態を保存しておけば、以降のテストでログイン処理をスキップできます。
認証フローの最適化
storageStateを活用した認証フローの全体像を見てみましょう。
【従来のフロー(テストごとにログイン)】
Test1: ログイン(5秒) → テスト実行
Test2: ログイン(5秒) → テスト実行
Test3: ログイン(5秒) → テスト実行
...
100テスト: 500秒(約8分)のログイン時間
【最適化後のフロー(storageState活用)】
Setup: ログイン(5秒) → 認証状態を保存
↓
Test1: 認証状態を復元 → テスト実行
Test2: 認証状態を復元 → テスト実行
Test3: 認証状態を復元 → テスト実行
...
100テスト: 5秒のログイン時間 ≈ 約8分の短縮!認証セットアップの実装
// tests/auth.setup.ts
import { test as setup } from '../fixtures';
import * as path from 'path';
import * as fs from 'fs';
const authDir = path.join(__dirname, '.auth');
// 認証ディレクトリの作成
if (!fs.existsSync(authDir)) {
fs.mkdirSync(authDir, { recursive: true });
}
setup('管理者ユーザーの認証', async ({ loginPage, page }) => {
await loginPage.goto();
await loginPage.login(
process.env.E2E_ADMIN_EMAIL!,
process.env.E2E_ADMIN_PASSWORD!
);
// ダッシュボードが表示されるまで待機
await page.waitForURL('/dashboard');
// 認証状態を保存
await page.context().storageState({
path: path.join(authDir, 'admin.json')
});
});
setup('一般ユーザーの認証', async ({ loginPage, page }) => {
await loginPage.goto();
await loginPage.login(
process.env.E2E_USER_EMAIL!,
process.env.E2E_USER_PASSWORD!
);
await page.waitForURL('/dashboard');
await page.context().storageState({
path: path.join(authDir, 'user.json')
});
});Playwright設定での認証状態の利用
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
// セットアッププロジェクト(認証状態の作成)
{
name: 'setup',
testMatch: '<strong>/auth.setup.ts',
},
// 管理者ユーザーのテスト
{
name: 'admin-tests',
dependencies: ['setup'],
use: {
...devices['Desktop Chrome'],
storageState: 'tests/.auth/admin.json',
},
testMatch: '</strong>/admin/**/*.spec.ts',
},
// 一般ユーザーのテスト
{
name: 'user-tests',
dependencies: ['setup'],
use: {
...devices['Desktop Chrome'],
storageState: 'tests/.auth/user.json',
},
testMatch: '<strong>/user/</strong>/*.spec.ts',
},
// 未認証状態のテスト
{
name: 'unauthenticated-tests',
use: {
...devices['Desktop Chrome'],
storageState: { cookies: [], origins: [] },
},
testMatch: '<strong>/public/</strong>/*.spec.ts',
},
],
});この設定により、以下の流れでテストが実行されます:
setupプロジェクトで認証状態を作成・保存- 各テストプロジェクトは保存された認証状態を使用
- テストごとにログイン処理を行う必要がなくなる
効果: 100件のテストがあり、それぞれログインに5秒かかる場合、約8分の時間短縮が期待できます。
環境変数による設定管理
機密情報や環境依存の設定は、環境変数で管理することが重要です。
// config/test-config.ts
import * as dotenv from 'dotenv';
import * as path from 'path';
// .envファイルの読み込み
dotenv.config({ path: path.join(__dirname, '..', '.env') });
export interface TestConfig {
baseUrl: string;
credentials: {
admin: { email: string; password: string };
user: { email: string; password: string };
};
}
export const testConfig: TestConfig = {
baseUrl: process.env.E2E_BASE_URL || 'http://localhost:3000',
credentials: {
admin: {
email: process.env.E2E_ADMIN_EMAIL || '',
password: process.env.E2E_ADMIN_PASSWORD || '',
},
user: {
email: process.env.E2E_USER_EMAIL || '',
password: process.env.E2E_USER_PASSWORD || '',
},
},
};
// 設定の検証
export function validateConfig(): void {
const required = [
'E2E_BASE_URL',
'E2E_ADMIN_EMAIL',
'E2E_ADMIN_PASSWORD',
];
const missing = required.filter(key => !process.env[key]);
if (missing.length > 0) {
throw new Error(
`必須の環境変数が設定されていません: ${missing.join(', ')}`
);
}
}.env.exampleファイルを用意しておくと、チームメンバーが必要な環境変数を把握しやすくなります:
# .env.example
E2E_BASE_URL=http://localhost:3000
E2E_ADMIN_EMAIL=admin@example.com
E2E_ADMIN_PASSWORD=your_password
E2E_USER_EMAIL=user@example.com
E2E_USER_PASSWORD=your_password実際のテストコード例
ここまでの内容を統合した、実際のテストコードを見てみましょう。
// tests/user/settings.spec.ts
import { test, expect } from '../../fixtures';
test.describe('ユーザー設定', () => {
test('表示名を変更できる', async ({ settingsPage }) => {
await settingsPage.goto('/settings');
await settingsPage.updateDisplayName('新しい名前');
const message = await settingsPage.getToastMessage();
expect(message).toBe('設定を保存しました');
});
test('プロフィール画像をアップロードできる', async ({ settingsPage }) => {
await settingsPage.goto('/settings');
await settingsPage.uploadProfileImage('test-image.png');
expect(await settingsPage.hasProfileImage()).toBeTruthy();
});
});
test.describe('認証が必要なページ', () => {
// このグループは認証済み状態(storageState)でテストされる
test('ダッシュボードにアクセスできる', async ({ dashboardPage }) => {
await dashboardPage.goto();
expect(await dashboardPage.isVisible()).toBeTruthy();
});
});
test.describe('未認証でのアクセス', () => {
// 未認証状態を明示的に設定
test.use({ storageState: { cookies: [], origins: [] } });
test('ダッシュボードにアクセスするとログインページにリダイレクトされる', async ({
page,
loginPage
}) => {
await page.goto('/dashboard');
// リダイレクトを確認
await page.waitForURL('/login');
expect(await loginPage.isLoginFormVisible()).toBeTruthy();
});
});実践で役立つベストプラクティス
Page Object設計のポイント
- 単一責任の原則: 1つのPage Objectは1つの画面または機能を担当
- ユーザー視点のメソッド名:
clickSubmitButton()ではなくsubmitForm() - ロケータの隠蔽: 内部的なセレクタはprivateプロパティとして隠蔽
- アサーションの分離: Page Objectではアサーションを行わない(テストコードで行う)
Fixtures設計のポイント
- 必要なものだけ提供: 使われないfixtureは初期化されないのでパフォーマンスに影響しない
- 適切なスコープ選択: 重い初期化はworker-scopedを検討
- クリーンアップの実装:
use()の後でリソースを確実に解放 - 型定義の徹底: TypeScriptの型システムを活用してIDEサポートを最大化
テスト設計のポイント
- 認証状態の再利用:
storageStateでテスト時間を短縮 - テストの独立性: 各テストは他のテストに依存しない
- 適切なグルーピング:
test.describe()で論理的にテストを整理 - 環境変数の活用: 機密情報はコードにハードコードしない
まとめ
PlaywrightのFixturesとPage Objectパターンを組み合わせることで、以下のメリットが得られます:
- コードの重複削減: Page Objectとfixturesによる再利用性の向上
- 保守性の向上: UIの変更に対する影響範囲の限定
- テスト実行時間の短縮: 認証状態の再利用による高速化
- 型安全な開発体験: TypeScriptによるIDE支援の最大化
E2Eテストの規模が大きくなるほど、これらの設計パターンの恩恵を受けることができます。ぜひ、プロジェクトの状況に合わせて取り入れてみてください。
参考文献
- Fixtures | Playwright - Playwright公式ドキュメントのFixtures解説
- Page object models | Playwright - Playwright公式のPage Objectパターン解説
- Page Object Model in Playwright with TypeScript: Best Practices - TypeScriptでのベストプラクティス
- Page Object Model with Playwright and TypeScript | CodiLime - 実践的な実装例
- Page Object Model Guide: Best Practices October 2025 - 最新のベストプラクティスガイド
