---
title: "Page Object Model（POM）とは？Playwright・Seleniumの実装例と保守コストの実測"
url: "https://www.issoh.co.jp/tech/details/10677/"
published: 2026-01-21
updated: 2026-10-19
categories: ["テスト"]
publisher: "株式会社一創"
---

# Page Object Model（POM）とは？Playwright・Seleniumの実装例と保守コストの実測

Page Object Model（POM）は、画面ごとの要素定義と操作手順をクラスに閉じ込め、テストコードからセレクタを追い出すE2Eテストの設計パターンです。この記事ではPlaywright 1.64.1で同じログイン画面のテストを3方式で書き、要素のidを改称したときに何ファイル・何箇所を直す必要があったかを実測しました。比較対象は、要素idの改称という限定したUI変更です。あわせて、fixture・`test.step`・認証状態の保存とPOMの組み合わせ方、SeleniumのPageFactoryを実際に動かしたときの挙動、Cypress公式がPage Objectの共有をアンチパターンに挙げている事情も整理します。

## まとめ：POMの効果と適用判断

- POMは「UI変更の修正箇所を1か所に集める」パターンです。Playwright 1.64.1での実測では、要素idの改称に対して直接セレクタ方式が3ファイル9箇所、POMが1ファイル3箇所でした。
- 修正箇所をゼロにするのはPOMではなくロケータの選び方です。`getByLabel`・`getByRole`で書いたPage Objectは、id改称後も1行も変更せずに3件とも通りました。
- POMは無料ではありません。同じ3テストで総行数は24行（3ファイル）から47行（4ファイル）へ増え、テストファイル1本あたりの行数は8行のまま変わりませんでした。テスト側が短くなるのはfixtureで注入したとき（1件4.5行）です。
- Page Objectのメソッドを`test.step`で包むとHTMLレポートに操作名が出ます。`box: true`を付けるとエラーの行表示がテスト側の呼び出し行だけになり、Page Object内のどの行で止まったかは消えます。
- 業務上の合否判定はテスト側に置きます。Selenium公式は`Generally don't make assertions`と書いたうえで、ページの読み込み確認だけはPage Object内で行ってよいと例外を明記しています。
- Cypressは公式のBest PracticesでPage Objectの共有をアンチパターンに挙げています。UI操作そのものを確かめるテストと、前提状態を作るだけの処理を分けて判断します。

## Page Object Model（POM）の定義と公式が示す6つの設計原則

### 定義：画面単位のクラスにUI操作を閉じる設計

[Selenium公式ドキュメントのPage object models](https://www.selenium.dev/documentation/test%5Fpractices/encouraged/page%5Fobject%5Fmodels/)はPOMを次のように定義しています。`Page Object Model is a Design Pattern that has become popular in test automation for enhancing test maintenance and reducing code duplication.`（テスト保守性を高め、コードの重複を減らすためにテスト自動化で普及した設計パターン）。続く説明では、UIが変わってもテスト自体を書き換える必要はなく、Page Object内のコードだけを変えれば済み、新しいUIへの対応が1か所に集まる点を利点として挙げています。

POMが減らすのは「修正箇所の数」であって「修正の発生」ではありません。セレクタが変われば直す作業は必ず発生します。

### POMの用語史：Window DriverとPage Object

パターンとしての原型は、Martin Fowlerが2004年8月26日の原稿[Window Driver](https://martinfowler.com/eaaDev/WindowDriver.html)で記述したものです。同氏は2013年9月10日に公開した[Page Object](https://martinfowler.com/bliki/PageObject.html)の記事で、page objectという呼び名について`was popularized by the Selenium web testing framework`（Seleniumのウェブテストフレームワークによって広まった）と説明しています。Selenium公式のPOM解説ページも、SeleniumのwikiにあったPageObjectsページを含む複数の出典をマージした内容だと自ら注記しています。

POMは特定ツールの機能ではなく、Seleniumコミュニティを経由して広まった設計上の約束事で、利用するツールのAPIを組み合わせて実装します。

### Selenium公式によるPOMの6つの設計原則

Selenium公式のPOM解説ページは、要点を6項目にまとめています。逐語と実務上の意味を並べます。

- `The public methods represent the services that the page or component offers`（公開メソッドは画面が提供する機能を表す）… `login()`のようなサービス名で切ります。
- `Try not to expose the internals of the page or component`（内部を露出させない）… セレクタやWebElementをなるべく外へ出さない、という設計上の目安です。
- `Generally don't make assertions`（原則としてアサーションを書かない）… 検証はテスト側に置きます。
- `Methods return other Page Objects, Page Component Objects, or optionally themselves`（メソッドは他のPage Objectか自身を返す）… 遷移先のPage Objectを返し、自身を返すfluent記法も可です。
- `Need not represent an entire page all the time`（常にページ全体を表す必要はない）… ヘッダーやモーダル単位でも構いません。
- `Different results for the same action are modelled as different methods`（同じ操作で結果が異なるなら別メソッドにする）… 成功と失敗を分けます。

Martin Fowlerの記事も3番目と同じ立場で、`Page objects are commonly used for testing, but should not make assertions themselves. Their responsibility is to provide access to the state of the underlying page.`（Page Objectはテストで使われるが、それ自身がアサーションをするべきではない。責務は背後のページの状態へのアクセスを提供することにある）と書いています。業務検証と読み込み確認の区別は後の章で扱います。

## UI変更の影響範囲をPlaywright 1.64.1で実測

### POM比較の検証環境とログイン画面

検証にはログイン画面（ユーザー名・パスワード・ログインボタン・エラー表示）とダッシュボードの2画面からなる静的アプリを用意し、同じ3件のシナリオ（ログイン成功、パスワード誤り、ログイン後のログアウトリンク表示）を方式ごとに実装しました。実行環境は次のとおりです（2026年10月19日に計測）。

| 項目                         | 値                                                |
| -------------------------- | ------------------------------------------------ |
| @playwright/test           | 1.64.1（npm公開 2026-10-14）                         |
| ブラウザ                       | Playwright同梱Chromium／Google Chrome 155.0.8059.82 |
| Node.js                    | v26.5.0                                          |
| selenium（PyPI）             | 4.51.0（2026-10-09公開）                             |
| pytest / pytest-playwright | 9.1.1 / 0.10.0（2026-10-08公開）                     |
| Java / selenium-java       | Temurin 25.0.4.1 / 4.51.0                        |

Playwrightの版ごとの変更点と更新手順は[Playwrightの最新バージョンの確認・更新方法とリリース履歴](/tech/details/9257/)にまとめています。題材のアプリは静的HTML 2枚で、フォーム送信をインラインのJavaScriptが処理し、`demo`と`secret123`が一致したときだけダッシュボードへ遷移します。以下に配信サーバーと設定を示します。サーバーは配信するファイルを許可リストで2枚に限り、127.0.0.1でだけ待ち受けます。掲載コードは抜粋で、再現にはアプリのHTMLと全テストファイルが別途必要です。

```
// server.js（app/ の2ファイルだけを 127.0.0.1:3210 で配信）
const http = require('http');
const fs = require('fs');
const path = require('path');

const root = path.join(__dirname, 'app');
const pages = { '/': 'login.html', '/login.html': 'login.html', '/dashboard.html': 'dashboard.html' };

http.createServer((req, res) => {
  const name = pages[req.url.split('?')[0]];
  if (!name) { res.writeHead(404); res.end('not found'); return; }
  fs.readFile(path.join(root, name), (err, buf) => {
    if (err) { res.writeHead(500); res.end('read error'); return; }
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    res.end(buf);
  });
}).listen(3210, '127.0.0.1');
```

```
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  reporter: [['list']],
  use: { baseURL: 'http://127.0.0.1:3210' },
  webServer: {
    command: 'node server.js',
    url: 'http://127.0.0.1:3210/login.html',
    reuseExistingServer: true,
  },
});
```

直接セレクタ方式3件、id依存のPOM方式3件、ユーザー可視ロケータのPOM方式3件、fixture版2件の計11テストを流し、変更前の状態で全件成功することを確認しました。テストを絞り込んで流すコマンドは[Playwright CLIコマンド一覧](/tech/details/17730/)にまとめています。

```
$ npx playwright test tests/raw tests/pom tests/robust tests/fixture
  ...
  11 passed (13.7s)
```

### 直接セレクタ方式：id改称後の3件失敗と9箇所の修正

Page Objectを使わない書き方では、同じセレクタが各テストファイルに繰り返し現れます。

```
// tests/raw/login-success.spec.ts
import { test, expect } from '@playwright/test';

test('正しい資格情報でダッシュボードへ遷移する', async ({ page }) => {
  await page.goto('/login.html');
  await page.locator('#username').fill('demo');
  await page.locator('#password').fill('secret123');
  await page.locator('#login-btn').click();
  await expect(page.locator('h1')).toHaveText('ダッシュボード');
});
```

ここでアプリ側の要素idを`username`から`user-name`、`password`から`pass`、`login-btn`から`submit-btn`へ改称しました。`label`の`for`属性は新しいidに合わせ、ラベル文字列とボタンの表示名は変えていません。直接セレクタ方式の3件はすべて失敗しました。

```
Error: locator.fill: Test timeout of 30000ms exceeded.
Call log:
  - waiting for locator('#username')
```

失敗の代償は時間にも出ます。要素が見つからないテストは既定の30秒タイムアウトを使い切るため、1件あたり30.1秒かかりました（成功時は1〜3秒）。CI全体の経過時間は並列ワーカー数や再試行の設定にも左右され、単純な件数×30秒にはなりません。修正が必要な箇所を数えると、3ファイルに9箇所でした。

```
$ grep -ro -E "#username|#password|#login-btn" tests/raw | wc -l
       9
$ grep -rl -E "#username|#password|#login-btn" tests/raw | wc -l
       3
```

### POM方式：1ファイル3行の修正で復旧

同じ3シナリオをPage Object経由にします。セレクタはコンストラクタでLocatorとして保持し、`login()`という画面の提供機能でメソッドを切ります。

```
// tests/pom/pages/LoginPage.ts
import { type Locator, type Page } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly username: Locator;
  readonly password: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.username = page.locator('#username');
    this.password = page.locator('#password');
    this.submitButton = page.locator('#login-btn');
    this.errorMessage = page.locator('#error');
  }

  async goto() {
    await this.page.goto('/login.html');
  }

  async login(user: string, pass: string) {
    await this.username.fill(user);
    await this.password.fill(pass);
    await this.submitButton.click();
  }
}
```

```
// tests/pom/login-failure.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

test('誤ったパスワードでエラーを表示する', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('demo', 'wrong');
  await expect(loginPage.errorMessage).toHaveText('ユーザー名またはパスワードが違います');
});
```

この方式でもid改称後は3件とも失敗します。POMは失敗を防ぐ仕組みではありません。違いは復旧作業で、`LoginPage.ts`のセレクタ3行だけを書き換えて再実行すると、3件が成功に戻りました。同じ時点で直接セレクタ方式の3件は失敗したままです。

```
$ npx playwright test tests/pom tests/raw
  ✓  1 tests/pom/login-failure.spec.ts:4:5 › 誤ったパスワードでエラーを表示する (2.2s)
  ✓  2 tests/pom/login-success.spec.ts:4:5 › 正しい資格情報でダッシュボードへ遷移する (2.5s)
  ✓  6 tests/pom/dashboard.spec.ts:4:5 › ログイン後にログアウトリンクを表示する (2.8s)
  ✘  4 tests/raw/login-success.spec.ts:3:5 › 正しい資格情報でダッシュボードへ遷移する (30.1s)
  ✘  5 tests/raw/login-failure.spec.ts:3:5 › 誤ったパスワードでエラーを表示する (30.1s)
  ✘  3 tests/raw/dashboard.spec.ts:3:5 › ログイン後にログアウトリンクを表示する (30.1s)
  3 failed
  3 passed (34.6s)
```

### ユーザー可視ロケータのPage Objectは修正ゼロで通過

3方式目として、セレクタをidからユーザーに見える属性へ置き換えたPage Objectも用意しました。Playwright公式は[ロケータの選び方](https://playwright.dev/docs/locators)について`To make tests resilient, we recommend prioritizing user-facing attributes and explicit contracts such as page.getByRole().`（テストを壊れにくくするため、ユーザーに見える属性と明示的な契約を優先することを推奨する）と案内しています。

```
// tests/robust/pages/LoginPage.ts（コンストラクタのみ抜粋）
constructor(page: Page) {
  this.page = page;
  this.username = page.getByLabel('ユーザー名');
  this.password = page.getByLabel('パスワード');
  this.submitButton = page.getByRole('button', { name: 'ログイン' });
  this.errorMessage = page.getByText('ユーザー名またはパスワードが違います');
}
```

id改称ではラベルやボタン名を変えていないため、このPage Objectは1行も修正せずに3件とも成功しました。3方式の実測値を並べます。

| 方式                        | id改称後の結果 | 修正ファイル数 | 修正箇所 |
| ------------------------- | -------- | ------- | ---- |
| 直接セレクタ（POMなし）             | 3件中3件失敗  | 3       | 9    |
| POM（id依存のロケータ）            | 3件中3件失敗  | 1       | 3    |
| POM（getByLabel・getByRole） | 3件中0件失敗  | 0       | 0    |

この比較で、POMによる修正箇所の集約と、ロケータ選択によるid改称への耐性は別の効果だと分かります。POMは修正箇所を集約し、ユーザー可視ロケータはラベルの関連付けとボタン名が保たれたid改称への依存をなくします。ラベルやロールそのものが変われば、ロケータの修正は必要です。POMを入れてもセレクタがCSSのidやクラスに依存していれば、改称のたびにPage Objectを開くことになります。

テストID属性を使う`getByTestId`の扱いは二段構えです。Playwright公式は`Testing by test ids is the most resilient way of testing`（テストIDによるテストは最も壊れにくい）と書き、QAと開発者が明示的なテストIDを定義して参照することを勧めています。同じ節はそのうえで`However testing by test ids is not user facing.`（ただしテストIDによるテストはユーザーに見えるものではない）と続け、roleやtextに意味がある場面ではユーザー可視ロケータを検討するよう案内しています。壊れにくさを最優先するならテストID、表示の文言やロール自体を保証したいならラベルやロール、という使い分けです。

### POM導入による実装量の増加：24行から47行

修正箇所が減る代わりに、書く量は増えます。同じ3テストで実装の総量を数えると、空行とコメント行を除いて直接セレクタ方式が24行（3ファイル）、POM方式が47行（4ファイル）でした。約2倍です。

Page Objectを除いたテストファイル1本あたりの行数は、直接セレクタ方式もid依存のPOM方式も8行で差がありません。`import`・`new LoginPage(page)`・`goto()`の記述が、消えたセレクタ行を埋め戻すためです。Page Objectの23行は固定費として残るので、今回と同じ8行のテストを両方式に足していく限り、総行数の差は縮まりません。長い操作手順を何本ものテストで再利用する場合は、この計算が変わります。

テスト側が実際に短くなったのは、fixtureで注入した場合の1件4.5行（spec 9行を2件で割った値。別に`fixtures.ts`が11行）でした。POM単体の見返りは行数ではなく、前節で測った修正箇所の集約（9箇所から3箇所）です。導入は、同じ操作の重複、画面の変更頻度、操作手順の複雑さで判断します。テストが1本で操作も短く、変更の見込みも小さい画面なら、クラスを追加せず直接書いて構いません。

## PlaywrightでのPOM実装：fixture・test.step・認証状態

### 公式作例の形：Pageを受け取るクラス

Playwright公式の[Page object models](https://playwright.dev/docs/pom)ページは、POMを必須の作法ではなく`Page object models are one such approach to structure your test suite.`（テストスイートを構造化するための1つのアプローチ）と位置づけています。実装形は前章のコードと同じで、コンストラクタで`Page`を受け取り、テスト側で`new`して操作メソッドを呼びます。公式のテストコード側は次の3行に要約されます。

```
const playwrightDev = new PlaywrightDevPage(page);
await playwrightDev.goto();
await playwrightDev.getStarted();
```

1.56で入ったPlaywright Test Agentsのgeneratorを併用する場合は、出力の形に注意します。[公式ドキュメント](https://playwright.dev/docs/test-agents)の出力例はspecの中で`page.getByRole`を直接呼ぶ形で、1.64.1に同梱のgenerator定義にもPage Objectを作る指示はありません。既存のPage Objectがあるプロジェクトでは、生成されたテストをPage Object経由に書き換えるかどうかをレビュー時に決めておくと、セレクタが二重に散らばるのを防げます。

### fixtureによるPage Objectの注入と初期化処理の集約

`new`と初期遷移を全テストに書くのは重複です。Playwrightのfixtureを使うと、Page Objectをテストの引数として受け取れます。公式の[fixtureドキュメント](https://playwright.dev/docs/test-fixtures)にも、`todoPage`と`settingsPage`という2つのfixtureを`that follow the Page Object Model pattern`（Page Object Modelパターンに沿うもの）として定義する例があります。

```
// tests/fixture/fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../robust/pages/LoginPage';

type Pages = { loginPage: LoginPage };

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

export { expect } from '@playwright/test';
```

```
// tests/fixture/login.spec.ts
import { test, expect } from './fixtures';

test('fixtureが注入したPage Objectでログインできる', async ({ loginPage, page }) => {
  await loginPage.login('demo', 'secret123');
  await expect(page.getByRole('heading')).toHaveText('ダッシュボード');
});

test('fixture経由でもエラー表示を検証できる', async ({ loginPage }) => {
  await loginPage.login('demo', 'wrong');
  await expect(loginPage.errorMessage).toBeVisible();
});
```

fixture版はテスト本体から`new`と`goto()`が消え、2件とも成功しました。`fixtures.ts`の11行は固定費なので、同じPage Objectを使うテストが増えるほど有利になります。

### test.stepとboxオプション：失敗箇所の表示と選択基準

Page Objectのメソッドを`test.step`で包むと、HTMLレポートに操作名がステップとして出ます。listレポーターでステップを1行ずつ表示するには、既定で`false`の`printSteps`を`[['list', { printSteps: true }]]`のように有効にします。v1.39で追加された`box`オプションについて、[公式のAPIリファレンス](https://playwright.dev/docs/api/class-test#test-step)は`An error inside a boxed step points to the step call site.`（boxしたステップ内のエラーは、ステップの呼び出し位置を指す）と説明しています。id改称後の画面に対して、boxの有無だけを変えた同じPage Objectを、タイムアウトを5秒に縮めて流しました（出力は抜粋）。

```
// tests/step/pages/LoginPage.ts（抜粋）
async login(user: string, pass: string) {
  await test.step('LoginPage.login', async () => {
    await this.username.fill(user);
    await this.password.fill(pass);
    await this.submitButton.click();
  }, { box: this.box });
}
```

```
  1) tests/step/boxed.spec.ts:4:5 › box: true › LoginPage.login
    Error: locator.fill: Test timeout of 5000ms exceeded.
    Call log:
      - waiting for locator('#username')

    > 6 |   await new LoginPage(page, true).login('demo', 'secret123');
        at /private/tmp/pomlab/tests/step/boxed.spec.ts:6:3

  2) tests/step/unboxed.spec.ts:4:5 › box: false › LoginPage.login
    Error: locator.fill: Test timeout of 5000ms exceeded.
    Call log:
      - waiting for locator('#username')

    > 17 |       await this.username.fill(user);
        at _test.test.step.box (/private/tmp/pomlab/tests/step/pages/LoginPage.ts:17:27)
        at LoginPage.login (/private/tmp/pomlab/tests/step/pages/LoginPage.ts:16:5)
        at /private/tmp/pomlab/tests/step/unboxed.spec.ts:6:3
```

boxありではスタックトレースからPage Objectのファイルが消え、テスト側の1行だけが示されました。どちらもステップ名`LoginPage.login`と`Call log`のロケータ`#username`は残ります。テストを読む側が「どの操作で落ちたか」だけ知れば足りるならbox、セレクタ修正の担当者がPage Objectの行番号まで追うならboxなし、という選び方になります。セレクタの保守をPage Objectに集めるのがPOMの目的なので、Page Object側の行を消すboxは、ロケータ名で検索できる規模にとどめるのが無難です。

### 認証状態（storageState）の保存とPage Objectとの併用

ログイン画面のPage Objectを全テストで呼ぶと、ログイン以外を確かめたいテストまでUIのログインを毎回通ります。Playwright公式の[認証ガイド](https://playwright.dev/docs/auth)は、サーバー側の状態を変更しないテストについて`Authenticate once in the setup project, save the authentication state, and then reuse it to bootstrap each test already authenticated.`（setup projectで一度だけ認証して状態を保存し、各テストを認証済みの状態から始める）方式を推奨しています。保存先の`playwright/.auth`は`.gitignore`に加えるよう案内されています。

```
// tests/auth/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import { LoginPage } from '../robust/pages/LoginPage';

const authFile = 'playwright/.auth/user.json';

setup('ログインして認証状態を保存する', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('demo', 'secret123');
  await expect(page.getByRole('heading', { name: 'ダッシュボード' })).toBeVisible();
  await page.context().storageState({ path: authFile });
});
```

```
// playwright.auth.config.ts（抜粋）
testDir: './tests/auth',
projects: [
  { name: 'setup', testMatch: /.*\.setup\.ts/ },
  {
    name: 'logged-in',
    testMatch: /.*\.spec\.ts/,
    use: { storageState: 'playwright/.auth/user.json' },
    dependencies: ['setup'],
  },
],
```

検証アプリをCookieでセッションを持つ形に変えて流すと、setupが768ms、保存済みの状態を使うダッシュボードのテストが401msで成功しました。保存されたJSONには`session`のCookieが入っています。同じテストを`storageState`なしで流すと、ログイン画面へ戻されてログアウトリンクが見つからず失敗しました。ログイン画面のPage Objectは、setupとログイン画面そのもののテストだけが使う形になります。管理者と一般ユーザーを1つのテストで同時に扱う場合は、同じガイドの`Testing multiple roles with POM fixtures`の節に、ロールごとのPage Objectをfixtureとして渡す例があります。

### ディレクトリ構成：ページ定義とテストの分離

検証で使った構成は次の形です。Page Objectを各方式の`pages`に置き、テストは機能単位でファイルを分けています。

```
app/
  login.html
  dashboard.html
server.js
playwright.config.ts
playwright.auth.config.ts
playwright/.auth/user.json   # .gitignore に入れる
tests/
  raw/        login-success / login-failure / dashboard の3 spec
  pom/
    pages/LoginPage.ts
    login-success.spec.ts ほか2本
  robust/
    pages/LoginPage.ts
    login-success.spec.ts ほか2本
  fixture/
    fixtures.ts
    login.spec.ts
  step/       boxed.spec.ts / unboxed.spec.ts / pages/LoginPage.ts
  auth/       auth.setup.ts / dashboard.spec.ts
  builder/    UserBuilder.ts / login.spec.ts
```

Playwrightの`testMatch`の既定は`.spec.`や`.test.`を含むファイル名なので、`pages`のようなクラス置き場を`testDir`配下に置いてもテストとして拾われません。導入やブラウザの取得でつまずく場合は[Playwrightのインストール方法とできない時の対処](/tech/details/3563/)を参照してください。

## SeleniumとPythonでのPOM実装

### Selenium 4.51.0とPythonでの実装

Seleniumでは要素の特定方法を`By`のタプルとしてクラス定数に置き、待機を`WebDriverWait`に任せる形が扱いやすい構成です。selenium 4.51.0とChrome 155.0.8059.82で実行し、ログイン成功と失敗の2件が成功しました（14.70秒）。

```
# sel/pages/login_page.py
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.wait import WebDriverWait


class LoginPage:
    URL = "http://127.0.0.1:3210/login.html"
    USERNAME = (By.ID, "username")
    PASSWORD = (By.ID, "password")
    SUBMIT = (By.ID, "login-btn")
    ERROR = (By.ID, "error")

    def __init__(self, driver, timeout=10):
        self.driver = driver
        self.wait = WebDriverWait(driver, timeout)

    def open(self):
        self.driver.get(self.URL)
        return self

    def login(self, user, password):
        self.wait.until(EC.visibility_of_element_located(self.USERNAME)).send_keys(user)
        self.driver.find_element(*self.PASSWORD).send_keys(password)
        self.driver.find_element(*self.SUBMIT).click()

    def error_text(self):
        return self.wait.until(EC.visibility_of_element_located(self.ERROR)).text
```

`open()`が`self`を返すのは、公式原則の4番目（メソッドは他のPage Objectか自身を返す）に沿ってfluentに連結できるようにするためです。テスト側は`LoginPage(driver).open().login("demo", "secret123")`と書けます。WebDriverの生成はpytestのfixtureに寄せ、`yield`後に`quit()`します。Selenium Managerによるドライバの自動管理を含む基本の書き方は[Seleniumの使い方とSelenium 4での書き方](/tech/details/4241/)で解説しています。

### PageFactoryと@FindBy（Java）の挙動と位置づけ

Javaには、フィールドに付けたアノテーションから要素を解決する`PageFactory`があります。4.51.0のソースでも`selenium-support`に`PageFactory.java`と`FindBy.java`が含まれ、`selenium-java`の依存だけで使えます。一方、Selenium公式の[PageFactoryの解説](https://www.selenium.dev/documentation/legacy/selenium%5F2/page%5Ffactory/)はLegacy（Selenium 2）の区分に置かれ、冒頭で`For current guidance, read about Page Object Models.`（現在の指針はPage Object Modelsを読むこと）と案内しています。APIに非推奨の注記は付いていませんが、公式の説明の主軸はすでにPOMのページに移っています。

```
// LoginPage.java（selenium-java 4.51.0・package と import は省略）
public class LoginPage {
  @FindBy(id = "username") private WebElement username;
  @FindBy(id = "password") private WebElement password;
  @FindBy(id = "login-btn") private WebElement submit;

  public LoginPage(WebDriver driver) {
    PageFactory.initElements(driver, this);
  }

  public void login(String user, String pass) {
    username.sendKeys(user);
    password.sendKeys(pass);
    submit.click();
  }
}
```

Temurin 25.0.4.1で実行すると、正しい画面ではダッシュボードへの遷移まで通りました。挙動の差が出たのはid改称後の画面です。`initElements`は要素が無くても成功し、最初の`sendKeys`で即座に例外になりました。ブラウザの起動を含めて5.5秒で終わりました。この比較は、Seleniumの暗黙的待機が既定の0秒のまま、Playwright Testが既定の30秒タイムアウトという条件での差です。

```
initElements OK
Exception in thread "main" org.openqa.selenium.NoSuchElementException: no such element: Unable to locate element: {"method":"css selector","selector":"#username"}
	at demo.LoginPage.login(LoginPage.java:18)
	at demo.Main.main(Main.java:16)
```

`@FindBy`が担うのはロケータの宣言だけで、要素は使う瞬間に`WebDriver`経由で探されます。PageFactory自体は待機を足さないため、暗黙的待機を設定していなければ、描画の遅い画面では見つかる前に例外になります。動的な画面では前掲のPython例のように`WebDriverWait`を明示する構成のほうが追跡しやすくなります。新しく書くなら、`By`を定数に持つ形を勧めます。

### pytest-playwrightでのPython実装

PythonでPlaywrightを使う場合は、`page`のfixtureを提供するpytestプラグインがあるため、Page Objectはそれを受け取るだけで済みます。playwright 1.64.0、pytest 9.1.1、pytest-playwright 0.10.0で2件とも成功しました（3.40秒）。

```
# py/pages/login_page.py
from playwright.sync_api import Page


class LoginPage:
    def __init__(self, page: Page):
        self.page = page
        self.username = page.get_by_label("ユーザー名")
        self.password = page.get_by_label("パスワード")
        self.submit_button = page.get_by_role("button", name="ログイン")
        self.error_message = page.get_by_text("ユーザー名またはパスワードが違います")

    def goto(self):
        self.page.goto("http://127.0.0.1:3210/login.html")

    def login(self, user: str, password: str):
        self.username.fill(user)
        self.password.fill(password)
        self.submit_button.click()
```

```
# py/test_login.py
from playwright.sync_api import Page, expect

from pages.login_page import LoginPage


def test_login_success(page: Page):
    login_page = LoginPage(page)
    login_page.goto()
    login_page.login("demo", "secret123")
    expect(page.get_by_role("heading")).to_have_text("ダッシュボード")


def test_login_failure(page: Page):
    login_page = LoginPage(page)
    login_page.goto()
    login_page.login("demo", "wrong")
    expect(login_page.error_message).to_be_visible()
```

Playwright公式のPython版ドキュメントも`Page object models wrap over a Playwright Page.`（Page ObjectはPlaywrightのPageをラップする）と述べ、同期・非同期の両方の実装例を載せています。TypeScript版のようにPage Object自体をfixtureとして渡す完成例は公式に見当たらないため、必要ならconftest側で自分のfixtureを定義します。

## Page Objectのアサーション配置：業務検証と準備状態の確認

アサーションの配置は、業務上の結果を検証するのか、ページの読み込み状態を確認するのかで分けて考えます。Selenium公式は原則を`Generally don't make assertions`と書き、Martin Fowlerも前掲のとおりPage Object自身がアサーションをするべきではないと明言しています。

一方、Playwright公式のPage object modelsページの作例は、`getStarted()`の内部で`await expect(this.gettingStartedHeader).toBeVisible();`を呼んでいます。確認しているのは遷移先の見出しの表示です。Selenium公式は例外として`There is one, single, verification which can, and should, be within the page object and that is to verify that the page, and possibly critical elements on the page, were loaded correctly.`（ページと重要な要素が正しく読み込まれたかの確認だけは、Page Object内で行ってよく、行うべきである）と書いているため、アサーションを含むこと自体は原則違反ではありません。違うのは置き場所で、Selenium公式はこの確認をインスタンス生成時に行うとし、Playwrightの作例は操作メソッドの中に置いています。

業務上の結果を判定するアサーションは、操作メソッドから分離します。理由は再利用性です。成功と失敗の両方を確かめたい操作（ログインはその典型）でメソッド内にアサーションを埋めると、異常系のテストからそのメソッドを呼べなくなります。前掲の`login()`がクリックまでで止めてあるのは、同じメソッドを成功系と失敗系の両方から呼ぶためです。

Page Object内に置いてよいのは、遷移先に着いたことを確かめる`expect(...).toBeVisible()`のような読み込み確認です。自動リトライがあるので明示的な待機コードを書かずに済みますが、条件を満たさなければテストを失敗させるアサーションである点は変わりません。純粋に待つだけなら`locator.waitFor()`も使えますが、タイムアウトすれば例外になり、未処理ならテストは失敗します。Page Object内に置くなら`expectLoaded()`のように目的が分かる名前にして、業務的な検証と混ぜないことです。

Page Object内で失敗したときの表示も確かめました。`test.step`を使わない素のPage Objectでは、エラー出力が`at LoginPage.login (LoginPage.ts:24:25)`と`at dashboard.spec.ts:7:19`の両方を示しました。前掲のbox付きステップにすると、このうちPage Object側の行が消えます。

## CypressのPOM共有に対する注意とApplication actionsの適用範囲

POMはどのツールでも推奨されている、という前提は誤りです。Cypressの公式[Best Practices](https://docs.cypress.io/app/core-concepts/best-practices)は、Organizing Tests, Logging In, Controlling Stateの節で`Anti-Pattern: Sharing page objects, using your UI to log in, and not taking shortcuts.`（Page Objectの共有、UIを使ったログイン、ショートカットを使わないこと）を挙げています。公式Recipesでは`Application actions are a replacement for Page Objects`（Application actionsはPage Objectsの置き換えである）と説明されています。

背景は2019年1月3日の[Cypress公式ブログ記事](https://www.cypress.io/blog/stop-using-page-objects-and-start-using-app-actions)（Gleb Bahmutov）にあります。同記事は`Page objects are hard to maintain and take away time from actual application development.`（Page Objectは保守が難しく、本来のアプリ開発の時間を奪う）と書き、セレクタがコンパイラで検査されないためUI変更が実行時に初めて失敗すること、UIを必ず経由するのでテストが遅くなることなどを挙げ、アプリの内部APIを直接叩くApplication actionsを提案しています。

ただし同記事は冒頭に`This article was an opinion piece about why app actions are better than page objects. The page object pattern is well known in the QA community, and widely and successfully used in the Cypress ecosystem.`（この記事はapp actionsがpage objectsより優れている理由についての意見記事である。page objectパターンはQAコミュニティで広く知られ、Cypressのエコシステムでも広く成功裏に使われている）と留保を置いています。

実務では次の切り分けが噛み合います。テストの目的が「その画面がユーザー操作で動くこと」ならPOMでUIを経由し、目的が「別の機能を検証するための前提条件を作ること」ならログインやデータ投入はAPIや保存済みの状態で済ませます。前章のstorageStateも、Cypressの`cy.session`も後者の手段で、POMの否定ではありません。ツールごとの設計思想は[CypressのE2E・コンポーネントテストの仕組み](/tech/details/3725/)や[Playwrightの仕組みとSeleniumとの違い](/tech/details/3167/)とあわせて把握すると判断しやすくなります。

## POM導入時の分割・継承・メソッド設計

### Page Objectの分割単位：画面とコンポーネント

公式原則の5番目が`Need not represent an entire page all the time`としているとおり、単位は画面に限りません。Selenium公式はこの考え方をPage Component Objectsとして説明し、Martin Fowlerが早い時期にpanel objectsという語で指摘していたことに触れています。全画面共通のヘッダー、検索モーダル、一覧テーブルの行などは、コンポーネント単位のオブジェクトに切り出したほうが重複が減ります。1画面を1クラスに押し込めた結果、数百行のクラスができたら分割の目安です。

### BasePageの継承条件：共通操作の有無

共通基盤としてBasePageを作り全Page Objectに継承させる構成は広く紹介されていますが、共通メソッドが実質ゼロのまま継承だけ並べても保守性は上がりません。継承を入れる基準は、2つ以上のPage Objectが同じ操作（共通ヘッダーのナビゲーション、トースト通知の取得など）を必要としているかどうかです。該当しなければ、素直にクラスを並べます。

### Page Objectのメソッド設計：要素取得と操作の単位

要素ごとにgetterを作ってテスト側で組み立てる書き方では、セレクタの置き場が変わるだけで手順の重複は残ります。公式原則の1番目が示すのは`usernameInput()`のような要素の露出ではなく`login()`のようなサービスの提供です。6番目は、遷移先のPage Objectを返す設計で効きます。成功時にダッシュボードのPage Objectを返すなら、失敗用は`loginExpectingError()`として分けます。本記事のように操作だけを行って結果をテスト側で検証する設計なら、共通の`login()`で足ります。

一方で本記事のPlaywright例は、`errorMessage`をLocatorのまま公開しています。テスト側の`expect`に渡して自動リトライ付きで検証するためで、Playwright公式の作例も`readonly getStartedLink: Locator;`のようにLocatorを公開しています。掲載例では比較を単純にするため入力欄やボタンのLocatorも`readonly`で公開していますが、検証に使わないLocatorは`private readonly`にして操作メソッド経由でだけ使う構成を勧めます。

### テストデータのBuilderとPage Objectの分担

入力値の組み合わせが増えると、Page Objectのメソッドに引数を足したくなります。入力値の組み立てはPage Objectの責務ではないので、テストデータ用のBuilderに分けます。Page Objectは「画面をどう操作するか」、Builderは「何を入力するか」を持つ分担です。

```
// tests/builder/UserBuilder.ts
export type Credentials = { user: string; pass: string };

export class UserBuilder {
  private creds: Credentials = { user: 'demo', pass: 'secret123' };

  withPassword(pass: string) {
    this.creds = { ...this.creds, pass };
    return this;
  }

  build(): Credentials {
    return { ...this.creds };
  }
}
```

```
// tests/builder/login.spec.ts（テスト本体）
const { user, pass } = new UserBuilder().withPassword('wrong').build();
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login(user, pass);
await expect(loginPage.errorMessage).toBeVisible();
```

このテストも1.64.1で成功しました。既定値をBuilderに1か所だけ持たせると、テストには「パスワードだけ違う」という差分だけが残ります。

### E2Eテストの配分とPOM導入の優先順位

POMはE2Eテストの保守コストを下げる手段ですが、E2Eの本数そのものを減らすほうが効く場面もあります。どの層に何本置くかという配分は[Testing Trophyとテストピラミッドの違い](/tech/details/8765/)で扱っています。CIでの実行時間やテストの配分を含めて自動化の仕組みを整える場合は、[DevOps・CI/CD導入支援](/service/system/devops/)でもご相談いただけます。

## よくある質問

### POMとPage Object Patternは違うものですか？

同じものを指します。Martin Fowlerの記事や英語圏のドキュメントではPage Object（あるいはPage Object Pattern）、テスト自動化の現場ではPage Object Model（POM）と呼ばれることが多い、という呼称の違いです。Selenium公式ドキュメントのページ名はPage object modelsで、Seleniumのwiki（PageObjects）の内容をマージしたものだと注記されています。

### PlaywrightでPOMは必須ですか？

必須ではありません。Playwright公式はPOMをテストスイートを構造化するための1つのアプローチと位置づけています。実測では3テスト規模で総行数が24行から47行へ増えました。同じ画面を触るテストが増える見込みがない段階では、導入を遅らせて構いません。

### data-testidを付けてからPOMを書くべきですか？

まずラベルやロールで取れるかを確認します。Playwright公式はユーザーに見える属性と明示的な契約を優先するよう推奨し、テストIDには`However testing by test ids is not user facing.`と留保を付けています。実測では、ラベルとボタン名で書いたPage Objectは要素idの改称後も修正ゼロで通りました。`data-testid`は、ユーザー可視ロケータで特定しにくい箇所や、表示文言から独立したテスト用の契約を設けたい箇所に使います。

### Seleniumの@FindByとPageFactoryは今でも使えますか？

使えます。4.51.0でも`selenium-support`に含まれ、実行して動作を確かめました。ただし公式の解説はLegacy（Selenium 2）の区分に移され、現在の指針としてPage Object Modelsのページを案内しています。要素は使う瞬間に探され、PageFactory自体は待機を足しません。WebDriverの暗黙的待機を設定していなければ、`WebDriverWait`などで待機を別に書く必要があります。

### Page Objectの中でアサーションを書いてはいけませんか？

合否判定はテスト側に置きます。Selenium公式は`Generally don't make assertions`を原則とし、ページの読み込み確認だけを例外として認めています。成功と失敗の両方から呼ぶ操作メソッドにはアサーションを入れず、遷移到達の確認を`expectLoaded()`のような別メソッドに切り出すと衝突しません。

## 関連記事

- [Playwright（プレイライト）とは？仕組み・できること・Seleniumとの違いを解説](/tech/details/3167/)
- [Seleniumの使い方｜Selenium 4でWebDriverの手動設定が不要になった書き方](/tech/details/4241/)
- [Playwrightのインストール方法とできない時の対処｜バージョン確認・Windows・Python](/tech/details/3563/)
- [Cypressとは｜E2E・コンポーネントテストの仕組みとcy.mountの書き方](/tech/details/3725/)
- [Testing Trophy（テスティングトロフィー）とは？Kent C. Doddsの4層とテストピラミッドとの違い](/tech/details/8765/)

---

出典: [Page Object Model（POM）とは？Playwright・Seleniumの実装例と保守コストの実測](<https://www.issoh.co.jp/tech/details/10677/>)（株式会社一創）
