テストコードの保守性を高めるPlaywrightのPage Object Model入門 〜Percy連携までの実践的アプローチ〜
情報システム室の進地@日比谷です。
E2Eテストにおいて、テストコードの保守性は重要な課題の一つです。この記事では、PlaywrightでのPage Object Modelの実装方法と、Visual Regression TestingツールのPercyとの連携について、実践的なアプローチをご紹介します。
なぜPage Object Modelが必要か
テストコードの保守性の課題
まず、Page Object Modelを使用しない一般的なテストコードを見てみましょう。
test('ログインテスト', async ({ page }) => {
await page.goto('https://example.com/login');
await page.fill('#email', 'test@example.com');
await page.fill('#password', 'password123');
await page.click('.login-button');
await expect(page).toHaveURL('https://example.com/dashboard');
});
test('ログイン失敗テスト', async ({ page }) => {
await page.goto('https://example.com/login');
await page.fill('#email', 'wrong@example.com');
await page.fill('#password', 'wrongpass');
await page.click('.login-button');
await expect(page.locator('.error-message')).toBeVisible();
});
このコードには次のような問題があります。
-
セレクタの重複
- 同じセレクタが複数のテストで繰り返し登場してしまいます
- セレクタが変更された場合、すべてのテストを修正する必要があります
-
操作手順の重複
- ログイン操作のような共通の手順が複数のテストで重複してしまいます
- 手順が変更された場合、すべてのテストを修正する必要があります
-
テストの意図が見えにくい
- 具体的な操作手順が並ぶだけで、テストが何を検証したいのか分かりにくくなっています
プロジェクトの構造
Page Object Modelを効果的に実装するために、以下のようなディレクトリ構造を推奨します。
tests/
├── pages/
│ ├── base/
│ │ └── BasePage.ts # 基底クラス
│ ├── selectors/
│ │ ├── LoginPageSelectors.ts
│ │ └── ProductPageSelectors.ts
│ ├── LoginPage.ts
│ └── ProductPage.ts
├── components/
│ ├── Header.ts
│ ├── Advertisement.ts
│ └── ResponsiveHelper.ts
└── specs/
├── login.spec.ts
├── product.spec.ts
└── responsive.spec.ts
ディレクトリの役割
- pages/: ページオブジェクトを格納します
- base/: 基底クラスや共通ユーティリティを配置します
- selectors/: セレクタの定義ファイルを管理します
- components/: 再利用可能なUIコンポーネントを配置します
- specs/: テストケースファイルを配置します
モジュールの依存関係
specs/ → pages/ → components/
↓
base/
↓
selectors/
このような構造にすることで:
- 関心の分離が明確になります
- コードの再利用が容易になります
- メンテナンス性が向上します
特に大規模なプロジェクトでは、この構造化が重要になってきます。
Page Object Modelの基本的な実装
まずは簡単な実装例を示します。
実装例
// pages/LoginPage.ts
export class LoginPage {
constructor(private page: Page) {}
async login(email: string, password: string) {
await this.enterEmail(email);
await this.enterPassword(password);
await this.clickLoginButton();
}
private async enterEmail(email: string) {
await this.page.fill('#email', email);
}
private async enterPassword(password: string) {
await this.page.fill('#password', password);
}
private async clickLoginButton() {
await this.page.click('.login-button');
}
async getErrorMessage() {
return this.page.locator('.error-message');
}
}
// tests/login.spec.ts
test('ログインテスト', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.login('test@example.com', 'password123');
await expect(page).toHaveURL('https://example.com/dashboard');
});
test('ログイン失敗テスト', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.login('wrong@example.com', 'wrongpass');
await expect(await loginPage.getErrorMessage()).toBeVisible();
});
より実践的な実装パターン
1. セレクタの管理
セレクタは変更されやすい要素なので、一箇所でまとめて管理します。
// pages/selectors/LoginPageSelectors.ts
export const LoginPageSelectors = {
emailInput: '[data-testid="email-input"]',
passwordInput: '[data-testid="password-input"]',
loginButton: '[data-testid="login-button"]',
errorMessage: '[data-testid="error-message"]'
} as const;
// pages/LoginPage.ts
import { LoginPageSelectors } from './selectors/LoginPageSelectors';
export class LoginPage {
constructor(private page: Page) {}
async login(email: string, password: string) {
await this.page.fill(LoginPageSelectors.emailInput, email);
await this.page.fill(LoginPageSelectors.passwordInput, password);
await this.page.click(LoginPageSelectors.loginButton);
}
}
2. 共通操作の抽象化
複数のページで共通する操作は基底クラスに抽象化します。
// pages/BasePage.ts
export abstract class BasePage {
constructor(protected page: Page) {}
async waitForPageLoad() {
await this.page.waitForLoadState('networkidle');
}
async navigateTo(path: string) {
await this.page.goto(`https://example.com${path}`);
await this.waitForPageLoad();
}
}
// pages/LoginPage.ts
export class LoginPage extends BasePage {
async navigate() {
await this.navigateTo('/login');
}
}
3. コンポーネントの再利用
共通のUIコンポーネントは再利用可能な形で実装します。
// components/Header.ts
export class Header {
constructor(private page: Page) {}
async clickLogo() {
await this.page.click('[data-testid="logo"]');
}
async search(keyword: string) {
await this.page.fill('[data-testid="search-input"]', keyword);
await this.page.click('[data-testid="search-button"]');
}
}
// pages/ProductPage.ts
export class ProductPage extends BasePage {
private header: Header;
constructor(page: Page) {
super(page);
this.header = new Header(page);
}
async searchProduct(keyword: string) {
await this.header.search(keyword);
await this.waitForPageLoad();
}
}
PercyとPage Object Modelの連携
Page Object Modelの基本的な実装方法を見てきましたが、これをVisual Regression TestingツールのPercyと組み合わせることで、さらに強力なテスト自動化が実現できます。
Percyのmaskオプション
Percyではmask
オプションを使用することで、特定の要素を比較対象から除外できます。これは以下のような場合に特に有用です:
- 広告など、表示内容が毎回変わる領域
- タイムスタンプなどの動的なテキスト
- A/Bテストによって変化する要素
// 基本的な使用例
await percySnapshot(page, 'スナップショット名', {
mask: [
'[data-testid="timestamp"]', // タイムスタンプ
'.advertisement', // 広告領域
'#dynamic-content' // 動的なコンテンツ
]
});
1. 動的コンテンツの制御
// components/Advertisement.ts
export class Advertisement {
constructor(private page: Page) {}
// 広告エリアのセレクタを一元管理
private selectors = {
adContainer: '[data-testid="ad-container"]',
sidebarAd: '.sidebar-advertisement',
headerAd: '.header-advertisement'
};
// Percy用のマスク処理を提供
async getMaskSelectors() {
return Object.values(this.selectors);
}
}
// pages/ArticlePage.ts
export class ArticlePage extends BasePage {
private advertisement: Advertisement;
constructor(page: Page) {
super(page);
this.advertisement = new Advertisement(page);
}
getAdvertisement() {
return this.advertisement;
}
async navigate() {
await this.navigateTo('/articles/');
}
// Percyスナップショット時に動的コンテンツをマスク
async takeSnapshot(name: string) {
await percySnapshot(this.page, name, {
mask: await this.advertisement.getMaskSelectors()
});
}
}
2. 状態管理との連携
// pages/ProductListPage.ts
export class ProductListPage extends BasePage {
private selectors = {
loadingSpinner: '[data-testid="loading-spinner"]',
productList: '[data-testid="product-list"]'
};
// データ読み込み完了まで待機してからスナップショット
async takeStableSnapshot(name: string) {
await this.page.waitForSelector(this.selectors.loadingSpinner, { state: 'hidden' });
await this.page.waitForSelector(this.selectors.productList, { state: 'visible' });
await percySnapshot(this.page, name);
}
}
3. レスポンシブデザインのテスト
// components/ResponsiveHelper.ts
export class ResponsiveHelper {
constructor(private page: Page) {}
private viewports = {
mobile: { width: 375, height: 667 },
tablet: { width: 768, height: 1024 },
desktop: { width: 1280, height: 800 }
};
// 各ビューポートでのスナップショットを一括取得
async takeResponsiveSnapshots(name: string, options?: PercyOptions) {
for (const [device, size] of Object.entries(this.viewports)) {
await this.page.setViewportSize(size);
await percySnapshot(this.page, `${name} - ${device}`, options);
}
}
}
// tests/responsive.spec.ts
test('レスポンシブデザインテスト', async ({ page }) => {
const articlePage = new ArticlePage(page);
const responsiveHelper = new ResponsiveHelper(page);
await articlePage.navigate();
// 広告をマスクしつつ、各デバイスでスナップショット
await responsiveHelper.takeResponsiveSnapshots('Article Page', {
mask: await articlePage.getAdvertisement().getMaskSelectors()
});
});
Percy連携のメリット
-
動的コンテンツの一貫した処理
- 広告やA/Bテスト領域などのマスク処理を一元管理できます
- 誤検知を削減できます
-
状態管理の簡素化
- ローディング状態や非同期更新の待機処理を抽象化できます
- 安定したスナップショットの取得が可能になります
-
レスポンシブテストの効率化
- ビューポートサイズの管理を一元化できます
- デバイス別のテストケース作成が容易になります
-
メンテナンス性の向上
- Visual Testingの設定変更が一箇所で可能です
- テスト失敗時の原因特定が容易になります
まとめ
Page Object Modelを使用することで、以下のような利点が得られます。
- テストコードの保守性が向上します
- コードの再利用性が向上します
- テストの可読性が向上します
- UIの変更に強いテストコードが実現できます
- チーム開発での効率が向上します
さらに、Percyと組み合わせることで、より堅牢なVisual Regression Testingが実現できます。特に動的コンテンツの管理や状態の安定化、レスポンシブデザインのテストにおいて、大きな効果を発揮します。