10日で覚えるPlaywrightDay 7: フィクスチャとPage Object Model

Day 7: フィクスチャとPage Object Model

今日学ぶこと

  • Playwrightのビルトインフィクスチャ(page, browser, context, request, browserName)
  • フィクスチャによるテスト分離の仕組み
  • test.extend() を使ったカスタムフィクスチャの作成
  • フィクスチャのスコープ(test vs worker)
  • フィクスチャの合成と依存関係
  • Page Object Model(POM)の概念と設計
  • POMクラスの作成とフィクスチャとの統合
  • プロジェクト内でのフィクスチャとPOMの整理方法
  • テストコードにおけるDRY vs WET

フィクスチャとは

フィクスチャは、テストの実行に必要な環境やデータを提供する仕組みです。Playwrightでは、テスト関数の引数としてフィクスチャを受け取ることで、ブラウザやページなどのリソースを自動的に管理できます。

flowchart TB
    subgraph Fixture["フィクスチャの役割"]
        Setup["セットアップ\nリソースの準備"]
        Test["テスト実行"]
        Teardown["ティアダウン\nリソースの破棄"]
        Setup --> Test --> Teardown
    end
    subgraph Isolation["テスト分離"]
        T1["テスト1\n独自のpage"]
        T2["テスト2\n独自のpage"]
        T3["テスト3\n独自のpage"]
    end
    style Fixture fill:#3b82f6,color:#fff
    style Isolation fill:#22c55e,color:#fff

ビルトインフィクスチャ

Playwrightは、すぐに使えるビルトインフィクスチャを提供しています。

page

最も頻繁に使うフィクスチャです。テストごとに新しいブラウザコンテキストとページが作成されます。

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

test('ページタイトルを確認', async ({ page }) => {
  await page.goto('https://example.com')
  await expect(page).toHaveTitle('Example Domain')
})

browser

ブラウザインスタンスそのものにアクセスします。複数のコンテキストを手動で作成したい場合に使います。

test('複数コンテキストで操作', async ({ browser }) => {
  const context1 = await browser.newContext()
  const context2 = await browser.newContext()
  const page1 = await context1.newPage()
  const page2 = await context2.newPage()

  // 異なるセッションで同時操作
  await page1.goto('https://example.com')
  await page2.goto('https://example.com')

  await context1.close()
  await context2.close()
})

context

現在のブラウザコンテキストにアクセスします。Cookie やストレージの操作に便利です。

test('Cookieを設定してテスト', async ({ context, page }) => {
  await context.addCookies([{
    name: 'session',
    value: 'abc123',
    domain: 'example.com',
    path: '/',
  }])

  await page.goto('https://example.com/dashboard')
  await expect(page.locator('.user-name')).toBeVisible()
})

request

ブラウザを使わずにAPIリクエストを送信できます。

test('APIからデータを取得', async ({ request }) => {
  const response = await request.get('https://api.example.com/users')
  expect(response.ok()).toBeTruthy()

  const users = await response.json()
  expect(users.length).toBeGreaterThan(0)
})

browserName

現在実行中のブラウザ名を取得できます。ブラウザ固有の処理に使います。

test('ブラウザに応じた処理', async ({ page, browserName }) => {
  test.skip(browserName === 'webkit', 'WebKitでは未対応')

  await page.goto('https://example.com')
  // Chromium/Firefox固有のテスト
})

ビルトインフィクスチャ一覧

フィクスチャ スコープ 説明
page test テストごとの分離されたページ
browser worker 共有ブラウザインスタンス
context test テストごとのブラウザコンテキスト
request test APIリクエストコンテキスト
browserName worker 実行中のブラウザ名

フィクスチャによるテスト分離

各テストは独立したフィクスチャを受け取るため、テスト間の干渉が起きません。

// テスト1とテスト2はそれぞれ別のpageを持つ
test('商品を検索', async ({ page }) => {
  await page.goto('https://shop.example.com')
  await page.fill('#search', 'keyboard')
  await page.click('button[type="submit"]')
  // このpageの状態はテスト2には影響しない
})

test('カートに追加', async ({ page }) => {
  await page.goto('https://shop.example.com/product/1')
  await page.click('#add-to-cart')
  // テスト1のpageとは完全に独立
})

カスタムフィクスチャの作成

test.extend() を使って、独自のフィクスチャを定義できます。

基本的なカスタムフィクスチャ

// fixtures.ts
import { test as base } from '@playwright/test'

// カスタムフィクスチャの型定義
type MyFixtures = {
  todoPage: Page
}

export const test = base.extend<MyFixtures>({
  todoPage: async ({ page }, use) => {
    // セットアップ: ページに移動してデータを準備
    await page.goto('https://demo.playwright.dev/todomvc/')
    await page.fill('.new-todo', 'Buy groceries')
    await page.press('.new-todo', 'Enter')
    await page.fill('.new-todo', 'Clean house')
    await page.press('.new-todo', 'Enter')

    // テストにフィクスチャを提供
    await use(page)

    // ティアダウン: クリーンアップ処理
    // use() の後に記述した処理はテスト後に実行される
  },
})

export { expect } from '@playwright/test'
// tests/todo.spec.ts
import { test, expect } from '../fixtures'

test('TODOが2件表示される', async ({ todoPage }) => {
  const items = todoPage.locator('.todo-list li')
  await expect(items).toHaveCount(2)
})

test('TODOを完了にする', async ({ todoPage }) => {
  await todoPage.locator('.todo-list li').first().locator('.toggle').click()
  const completed = todoPage.locator('.todo-list li.completed')
  await expect(completed).toHaveCount(1)
})

認証済みユーザーのフィクスチャ

import { test as base, Page } from '@playwright/test'

type AuthFixtures = {
  authenticatedPage: Page
}

export const test = base.extend<AuthFixtures>({
  authenticatedPage: async ({ page }, use) => {
    // APIでログイン
    await page.goto('/login')
    await page.fill('#email', 'user@example.com')
    await page.fill('#password', 'password123')
    await page.click('button[type="submit"]')
    await page.waitForURL('/dashboard')

    await use(page)

    // ログアウト処理
    await page.goto('/logout')
  },
})

フィクスチャのスコープ

フィクスチャには2つのスコープがあります。

testスコープ(デフォルト)

テストごとにセットアップ・ティアダウンが実行されます。

export const test = base.extend<{ tempDir: string }>({
  tempDir: async ({}, use) => {
    const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'test-'))
    await use(dir)
    await fs.rm(dir, { recursive: true })
  },
})

workerスコープ

ワーカープロセスごとに1回だけセットアップされます。コストの高い初期化に適しています。

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

type WorkerFixtures = {
  apiServer: string
}

export const test = base.extend<{}, WorkerFixtures>({
  apiServer: [async ({}, use) => {
    // サーバーを起動(ワーカーごとに1回)
    const server = await startTestServer()
    await use(server.url)
    await server.close()
  }, { scope: 'worker' }],
})
スコープ セットアップ頻度 用途
test テストごと ページ操作、テストデータ
worker ワーカーごと サーバー起動、DB接続

フィクスチャの合成と依存関係

フィクスチャは他のフィクスチャに依存できます。Playwrightが依存関係を自動的に解決します。

import { test as base, Page } from '@playwright/test'

type Fixtures = {
  dbConnection: DatabaseConnection
  seedData: TestData
  adminPage: Page
}

export const test = base.extend<Fixtures>({
  // 基盤となるフィクスチャ
  dbConnection: async ({}, use) => {
    const db = await connectToTestDB()
    await use(db)
    await db.close()
  },

  // dbConnectionに依存するフィクスチャ
  seedData: async ({ dbConnection }, use) => {
    const data = await dbConnection.seed({
      users: [{ name: 'Admin', role: 'admin' }],
      products: [{ name: 'Widget', price: 100 }],
    })
    await use(data)
    await dbConnection.cleanup()
  },

  // seedDataとpageに依存するフィクスチャ
  adminPage: async ({ page, seedData }, use) => {
    await page.goto('/login')
    await page.fill('#email', seedData.users[0].email)
    await page.fill('#password', 'password')
    await page.click('button[type="submit"]')
    await use(page)
  },
})
flowchart TB
    subgraph Dependencies["フィクスチャの依存関係"]
        DB["dbConnection"]
        Seed["seedData"]
        Admin["adminPage"]
        Page["page(ビルトイン)"]
        DB --> Seed
        Seed --> Admin
        Page --> Admin
    end
    style Dependencies fill:#8b5cf6,color:#fff

Page Object Model(POM)

Page Object Modelは、ページのUI操作をクラスにカプセル化するデザインパターンです。テストコードからページの実装詳細を隠蔽し、保守性を高めます。

flowchart LR
    subgraph Without["POMなし"]
        T1["テスト1\nセレクタ直書き"]
        T2["テスト2\nセレクタ直書き"]
        T3["テスト3\nセレクタ直書き"]
    end
    subgraph With["POMあり"]
        POM["LoginPage\nセレクタを一元管理"]
        TA["テスト1"]
        TB["テスト2"]
        TC["テスト3"]
        POM --> TA
        POM --> TB
        POM --> TC
    end
    style Without fill:#ef4444,color:#fff
    style With fill:#22c55e,color:#fff

POMクラスの作成

基本的なPOMクラス

// pages/login-page.ts
import { type Page, type Locator, expect } from '@playwright/test'

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

  constructor(page: Page) {
    this.page = page
    this.emailInput = page.locator('#email')
    this.passwordInput = page.locator('#password')
    this.submitButton = page.locator('button[type="submit"]')
    this.errorMessage = page.locator('.error-message')
  }

  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()
  }

  async expectError(message: string) {
    await expect(this.errorMessage).toHaveText(message)
  }
}

複数ページのPOM

// pages/dashboard-page.ts
import { type Page, type Locator, expect } from '@playwright/test'

export class DashboardPage {
  readonly page: Page
  readonly welcomeMessage: Locator
  readonly navMenu: Locator
  readonly logoutButton: Locator

  constructor(page: Page) {
    this.page = page
    this.welcomeMessage = page.locator('.welcome')
    this.navMenu = page.locator('nav')
    this.logoutButton = page.locator('#logout')
  }

  async expectWelcome(name: string) {
    await expect(this.welcomeMessage).toContainText(name)
  }

  async navigateTo(section: string) {
    await this.navMenu.getByRole('link', { name: section }).click()
  }

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

テストでの使用

// tests/login.spec.ts
import { test, expect } from '@playwright/test'
import { LoginPage } from '../pages/login-page'
import { DashboardPage } from '../pages/dashboard-page'

test('正常にログインできる', async ({ page }) => {
  const loginPage = new LoginPage(page)
  const dashboardPage = new DashboardPage(page)

  await loginPage.goto()
  await loginPage.login('user@example.com', 'password123')
  await dashboardPage.expectWelcome('User')
})

test('無効な認証情報でエラー表示', async ({ page }) => {
  const loginPage = new LoginPage(page)

  await loginPage.goto()
  await loginPage.login('wrong@example.com', 'wrong')
  await loginPage.expectError('Invalid credentials')
})

POMとフィクスチャの統合

POMをフィクスチャとして提供することで、テストコードをさらに簡潔にできます。

// fixtures.ts
import { test as base } from '@playwright/test'
import { LoginPage } from './pages/login-page'
import { DashboardPage } from './pages/dashboard-page'

type Pages = {
  loginPage: LoginPage
  dashboardPage: DashboardPage
}

export const test = base.extend<Pages>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page))
  },
  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page))
  },
})

export { expect } from '@playwright/test'
// tests/login.spec.ts
import { test, expect } from '../fixtures'

test('正常にログインできる', async ({ loginPage, dashboardPage }) => {
  await loginPage.goto()
  await loginPage.login('user@example.com', 'password123')
  await dashboardPage.expectWelcome('User')
})

プロジェクト構成

フィクスチャとPOMを整理した推奨ディレクトリ構成です。

project/
├── playwright.config.ts
├── fixtures/
│   ├── index.ts          # メインのフィクスチャ定義
│   ├── auth.fixtures.ts  # 認証関連フィクスチャ
│   └── db.fixtures.ts    # DB関連フィクスチャ
├── pages/
│   ├── login-page.ts
│   ├── dashboard-page.ts
│   ├── settings-page.ts
│   └── index.ts          # 全POMのre-export
├── tests/
│   ├── auth/
│   │   ├── login.spec.ts
│   │   └── register.spec.ts
│   ├── dashboard/
│   │   └── dashboard.spec.ts
│   └── settings/
│       └── settings.spec.ts
└── test-data/
    └── users.json

フィクスチャの統合例

// fixtures/index.ts
import { mergeTests } from '@playwright/test'
import { test as authTest } from './auth.fixtures'
import { test as dbTest } from './db.fixtures'

export const test = mergeTests(authTest, dbTest)
export { expect } from '@playwright/test'

DRY vs WET in テストコード

テストコードでは「DRY(Don't Repeat Yourself)」を過度に追求すると、かえって可読性が下がることがあります。

DRYが効果的な場面

// Good: POMによるセレクタの一元管理
export class ProductPage {
  readonly addToCartButton: Locator

  constructor(page: Page) {
    // セレクタが変わってもここだけ修正すればよい
    this.addToCartButton = page.locator('[data-testid="add-to-cart"]')
  }
}

WET(Write Everything Twice)が適切な場面

// Good: テストの意図が明確で、各テストが独立して読める
test('新規ユーザーがアカウントを作成できる', async ({ page }) => {
  await page.goto('/register')
  await page.fill('#name', 'Alice')
  await page.fill('#email', 'alice@example.com')
  await page.fill('#password', 'StrongPass123')
  await page.click('button[type="submit"]')
  await expect(page).toHaveURL('/welcome')
})

test('既存メールでの登録はエラーになる', async ({ page }) => {
  await page.goto('/register')
  await page.fill('#name', 'Bob')
  await page.fill('#email', 'existing@example.com')
  await page.fill('#password', 'StrongPass123')
  await page.click('button[type="submit"]')
  await expect(page.locator('.error')).toHaveText('Email already exists')
})

判断基準

観点 DRY(共通化) WET(重複許容)
セレクタ管理 POMで一元管理 -
セットアップ処理 フィクスチャで共通化 -
テストステップ - 各テストで明示的に記述
アサーション - テスト固有の検証を記述
ヘルパー関数 3回以上繰り返すなら共通化 2回までなら重複OK

テストコードは「実行可能なドキュメント」です。1つのテストを読むだけで、何をテストしているかが分かることが最も重要です。


まとめ

概念 説明
ビルトインフィクスチャ page, browser, context, request, browserName
test.extend() カスタムフィクスチャを定義するAPI
testスコープ テストごとにセットアップ・ティアダウン
workerスコープ ワーカーごとに1回だけ初期化
フィクスチャ合成 フィクスチャ間の依存関係を宣言的に定義
Page Object Model ページのUI操作をクラスにカプセル化
POM + フィクスチャ POMをフィクスチャとして提供し、テストを簡潔に

重要ポイント

  1. フィクスチャはテスト分離を保証し、セットアップ・ティアダウンを自動化する
  2. test.extend() でカスタムフィクスチャを作成し、テストの前提条件を宣言的に管理する
  3. POMはセレクタと操作を一元管理し、UIの変更に強いテストを実現する
  4. POMとフィクスチャを組み合わせることで、最も保守性の高いテストコードが書ける
  5. DRYとWETのバランスを取り、テストの可読性を最優先にする

練習問題

問題1: 基本

page フィクスチャに依存し、指定URLに移動済みの状態を提供するカスタムフィクスチャ homePage を作成してください。

問題2: 応用

ECサイトを想定した以下のPOMクラスを作成してください。

  • ProductListPage - 商品一覧の操作(検索、フィルタ、商品選択)
  • ProductDetailPage - 商品詳細の操作(カートに追加、数量変更)
  • CartPage - カートの操作(数量変更、削除、合計確認)

チャレンジ問題

上記のPOMクラスをフィクスチャとして統合し、以下のテストシナリオを実装してください:「商品を検索し、カートに追加し、カート内の合計金額を確認する」。workerスコープのフィクスチャでテスト用APIサーバーのURLを管理することも含めてください。


参考リンク


次回予告: Day 8では「デバッグとトレース」について学びます。Playwright Inspectorやトレースビューアを使って、テストの失敗原因を効率的に特定する方法を習得しましょう。