// この記事のポイント
- 型安全なカスタム Fixtureでテストコードの重複を排除し、依存関係を明示
- 3 層アーキテクチャの Page Object(コンポーネント / ページ / フロー)で UI 変更時の影響範囲を限定
- `storageState` で認証状態を再利用し、100 件規模のテストで合計約 8 分かかっていたログイン待機をほぼゼロに圧縮
- 副作用のある Fixture は test-scoped のまま、認証や DB セットアップは worker-scoped と使い分ける
はじめに
100件のE2Eテストがあり、それぞれのテストでログイン処理を行う場合、ログインだけで約8分かかる計算になります。さらにテストが増えるにつれ、同じログイン処理やPage Objectのインスタンス化が各ファイルに散在し、UI変更のたびに修正箇所の特定に時間を取られます。
本記事では、PlaywrightのFixtures機能とPage Objectパターンを組み合わせることで、これらの課題を解決するアプローチを紹介します。公式ドキュメントと実装例をもとに、スケーラブルなE2Eテスト設計の実践方法を解説していきます。
全体アーキテクチャ
まず、本記事で紹介する設計パターンの全体像を把握しておきましょう。

このアーキテクチャにより、テストコードはシンプルに保ちつつ、再利用性と保守性を両立できます。
Page ObjectパターンとFixturesの基礎
Page Objectパターンとは
Page Objectパターンは、Webページの構造と操作をクラスとしてカプセル化する設計パターンです。これにより、UIの変更が発生した際の修正箇所をPage Objectファイル内に限定でき、テストコード全体への波及を防ぎます。
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要素の変更時に修正箇所が1ファイルに集約される
- 操作の共有: 複数のテストが同じメソッドを呼び出す
- テストコードの見通し改善: ビジネスロジックに集中した記述が可能になる
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()の後に記述したクリーンアップコードをPlaywrightが実行する - 依存関係の自動解決: fixture間の依存グラフをPlaywrightが解析し、適切な順序で初期化する
実践的な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機能で認証済み状態をJSONに保存しておくと、100件のテストで約8分のログイン時間をほぼゼロに抑えられます。
用語解説:
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();
});
});やりがちなミスと対処法
ミス1: Page Objectにアサーションを書く
Page Objectのメソッド内でexpect()を呼ぶと、失敗時のエラーメッセージがテストコードから分離され、原因箇所の特定が難しくなります。
// NG: Page Object内でアサーション
async clickLogin(): Promise<void> {
await this.loginButton.click();
await expect(this.page).toHaveURL('/dashboard'); // ここに書かない
}
// OK: テストコード側でアサーション
test('ログイン後にダッシュボードへ遷移する', async ({ loginPage }) => {
await loginPage.login('user@example.com', 'password');
await expect(page).toHaveURL('/dashboard'); // テスト側で書く
});ミス2: すべてのfixtureをworker-scopedにする
worker-scopedはワーカー内で状態が共有されるため、あるテストが状態を変更すると後続テストに影響します。DBへの書き込みや認証トークンの無効化など、副作用のあるfixtureをworker-scopedにすると、テスト実行順序によって結果が変わります。副作用があるfixtureはデフォルトのtest-scopedのままにしてください。
ミス3: セレクタをテストコードと二重管理する
Page Objectを導入しても、テストコード内にpage.locator('#email-input')のような直接セレクタを残してしまうと、UI変更時に修正箇所が分散します。セレクタはPage Objectのコンストラクタに集約し、テストコードからは操作メソッドだけを呼ぶ設計を徹底してください。
ミス4: .envをコードにハードコードする
テスト用の認証情報をtests/auth.setup.tsに直接書くと、リポジトリに機密情報が混入します。process.env.E2E_ADMIN_EMAILのように環境変数経由で読み込み、.env.exampleをリポジトリに含める形が適切です。
まとめ
Page ObjectパターンとFixturesを組み合わせると、テストコードの重複が減り、UI変更時の修正コストが下がります。特にstorageStateによる認証状態の事前保存は、100件規模のテストで約8分の短縮になり、CI実行時間への効果が目に見えます。テストが増えるほど設計コストが回収できる仕組みなので、テストファイルが10本を超えたあたりで導入を検討してみてください。



