Page Object Modelとは?Playwright×Pythonでコードの保守性を高める設計パターン入門

Page Object Model(POM)は、テスト自動化コードの保守性・再利用性・可読性を劇的に向上させる、現場で最も広く使われている設計パターンです。

📌 この記事はこんな方におすすめ

  • テスト自動化を始めたばかりで、コードの整理方法がわからない方
  • スクリプトが増えるにつれてメンテナンスが大変になってきた方
  • Page Object Modelという言葉は聞いたことがあるが、実装方法がわからない方
  • Playwright・SeleniumでPOMを実践的に使いたいPythonエンジニア

✅ この記事を読むとわかること

  • Page Object Modelの概念と、なぜ必要なのかが理解できる
  • POMなしとPOMありのコードを比較して、違いが実感できる
  • Playwright(Python)でのPOM実装方法が具体的にわかる
  • 現場で使えるベストプラクティスが身につく

👤
この記事を書いた人:QAエンジニアとしてSelenium・Playwright・Python・pytestを使ったテスト自動化を実務で担当。Page Object Modelを導入してメンテナンスコストを大幅に削減した経験をもとに、初心者にも実践できるレベルで解説します。実装コードはGitHubにも公開中です。

📌 この記事の結論

Page Object Modelとは、「画面(ページ)ごとにクラスを作り、操作をメソッドとして定義する」設計パターンです。UIが変わってもテストコードの修正箇所が1か所で済み、メンテナンスコストを劇的に下げられます。テスト自動化を長期運用するなら、POMの導入は必須です。

テスト自動化を始めると、最初のうちはシンプルなスクリプトで十分です。しかしテストケースが増え、UIが変更されるたびに何十か所ものコードを修正しなければならないという状況に陥りがちです。「自動化したのにメンテが大変すぎる…」——その原因のほとんどは、設計パターンを使わずにテストコードを書いていることにあります。この記事では、そんな悩みを解決するPage Object Model(POM)について、概念から実装まで丁寧に解説します。

Page Object Modelとは?

Page Object Model(POM)とは、Webアプリケーションの各ページをクラスとして表現し、そのページの操作をメソッドとしてまとめる設計パターンです。2009年にSeleniumコミュニティで提唱され、現在はSelenium・Playwright・Cypressなど多くのテストフレームワークで標準的な設計手法として採用されています。

📐 POMのコアコンセプト

🖥️ Page Object

各ページの要素(ロケーター)と操作(メソッド)をまとめたクラス

🧪 Test Code

Page Objectのメソッドを呼び出すだけ。HTMLの詳細を知らなくてもいい

✅ 結果

UIが変わっても修正はPage Objectだけ。テストコードは変更不要

💡 ポイント:POMの本質は「テストの意図(何をテストしたいか)」と「実装の詳細(どのHTMLを操作するか)」を分離することです。この分離があることで、どちらかが変わっても影響範囲が最小限になります。

POMなしのコード:何が問題なのか?

まずPOMを使わないコードを見てみましょう。一見シンプルですが、テストが増えるにつれて深刻な問題が発生します。

❌ POMなし:よくある書き方

test_login_no_pom.py

import pytest
from playwright.sync_api import sync_playwright

def test_login_success():
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()

        # ログインページに移動
        page.goto("https://example.com/login")

        # メールアドレスを入力
        page.fill("#email", "user@example.com")

        # パスワードを入力
        page.fill("#password", "secret123")

        # ログインボタンをクリック
        page.click("#login-btn")

        # ダッシュボードが表示されることを確認
        assert page.is_visible("#dashboard")
        browser.close()

def test_login_failure():
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()

        # 同じセレクター(#email, #password, #login-btn)が再び登場!
        page.goto("https://example.com/login")
        page.fill("#email", "wrong@example.com")
        page.fill("#password", "wrongpass")
        page.click("#login-btn")

        # エラーメッセージの確認
        assert page.is_visible("#error-message")
        browser.close()

⚠️ この書き方の問題点

  • セレクター(#email など)が複数のテストに散らばっている。HTMLが変わったら全テストを修正する必要がある
  • 同じコードが重複している(DRY原則違反)。テストが100本になれば100か所を修正しなければならない
  • テストコードが「何をしているか」ではなく「どう操作するか」になっている。可読性が低い
  • ブラウザ操作の詳細がテストに混ざっており、テストの意図が読み取りづらい

POMを使ったコード:どう変わるか?

同じテストをPOMで書き直してみましょう。コードは少し増えますが、長期的なメンテナンスコストが劇的に下がります。

① Page Objectクラスを作る

pages/login_page.py

class LoginPage:
    """ログインページのPage Objectクラス"""

    def __init__(self, page):
        self.page = page

        # ロケーターをここに集約(←変更が必要な場合、ここだけ修正する)
        self.email_input    = "#email"
        self.password_input = "#password"
        self.login_button   = "#login-btn"
        self.dashboard      = "#dashboard"
        self.error_message  = "#error-message"

    def navigate(self):
        """ログインページへ移動"""
        self.page.goto("https://example.com/login")

    def login(self, email: str, password: str):
        """ログイン操作をひとつのメソッドにまとめる"""
        self.page.fill(self.email_input, email)
        self.page.fill(self.password_input, password)
        self.page.click(self.login_button)

    def is_dashboard_visible(self) -> bool:
        """ダッシュボードが表示されているか確認"""
        return self.page.is_visible(self.dashboard)

    def is_error_visible(self) -> bool:
        """エラーメッセージが表示されているか確認"""
        return self.page.is_visible(self.error_message)

② テストコードはシンプルになる

tests/test_login.py

import pytest
from playwright.sync_api import sync_playwright
from pages.login_page import LoginPage

@pytest.fixture
def login_page():
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()
        login_page = LoginPage(page)
        login_page.navigate()
        yield login_page
        browser.close()

def test_login_success(login_page):
    # ← セレクターが一切登場しない。テストの意図だけが書かれている
    login_page.login("user@example.com", "secret123")
    assert login_page.is_dashboard_visible()

def test_login_failure(login_page):
    login_page.login("wrong@example.com", "wrongpass")
    assert login_page.is_error_visible()

✅ POMにしたことで得られた改善

  • セレクターが LoginPage クラスに集約。HTMLが変わっても修正は1か所だけ
  • テストコードが自然言語に近いlogin_page.login() を呼ぶだけで何をしているかが一目でわかる
  • 新しいテストを追加しやすい。Page Objectのメソッドを呼ぶだけでよい

推奨ディレクトリ構成

POMを実践する際の、シンプルで使いやすいディレクトリ構成です。ページ数が増えても管理しやすい構造になっています。

project/
├── pages/                    # Page Objectクラスをまとめるディレクトリ
│   ├── base_page.py          # 全Pageに共通する処理(共通メソッド)
│   ├── login_page.py         # ログインページ
│   ├── dashboard_page.py     # ダッシュボードページ
│   └── profile_page.py       # プロフィールページ
├── tests/                    # テストコードをまとめるディレクトリ
│   ├── conftest.py           # fixtureの共通定義(browser, page など)
│   ├── test_login.py         # ログイン関連のテスト
│   ├── test_dashboard.py     # ダッシュボード関連のテスト
│   └── test_profile.py       # プロフィール関連のテスト
├── pytest.ini                # pytest設定ファイル
└── requirements.txt          # 依存パッケージ一覧

発展:Base Pageクラスで共通処理を集約する

複数のPage Objectで共通する処理(待機・スクロール・スクリーンショットなど)は、Base Pageクラスに集約するとさらに再利用性が高まります。

pages/base_page.py

class BasePage:
    """全Page Objectの基底クラス:共通処理をまとめる"""

    def __init__(self, page):
        self.page = page

    def wait_for_element(self, selector: str, timeout: int = 5000):
        """要素が表示されるまで待機"""
        self.page.wait_for_selector(selector, timeout=timeout)

    def take_screenshot(self, filename: str):
        """スクリーンショットを撮影"""
        self.page.screenshot(path=f"screenshots/{filename}.png")

    def scroll_to_bottom(self):
        """ページ最下部までスクロール"""
        self.page.evaluate("window.scrollTo(0, document.body.scrollHeight)")


class LoginPage(BasePage):
    """BasePage を継承してLoginPageを作る"""

    def __init__(self, page):
        super().__init__(page)   # ← BasePage の初期化を呼び出す
        self.email_input    = "#email"
        self.password_input = "#password"
        self.login_button   = "#login-btn"

    def login(self, email: str, password: str):
        self.wait_for_element(self.email_input)  # ← 継承した共通メソッドを使える
        self.page.fill(self.email_input, email)
        self.page.fill(self.password_input, password)
        self.page.click(self.login_button)

💡 実務Tip:Base Pageにはページ共通の待機処理・スクリーンショット・ログイン状態の確認など、どのページでも使いそうな処理をまとめるのがポイントです。共通処理が1か所になるので、変更や改善がしやすくなります。

POMのメリットとデメリット

✅ メリット ⚠️ デメリット・注意点
  • UIが変わっても修正箇所が1か所で済む
  • テストコードの可読性が上がる
  • コードの再利用性が高まる
  • テスト追加が容易になる
  • チームでの分業がしやすくなる
  • 初期設定に少し手間がかかる
  • テストが少ない段階では過剰設計になる場合も
  • Page Objectが肥大化しすぎないよう設計が必要
  • チーム全員がPOMを理解していないと効果が半減

⚠️ 注意:POMは万能ではありません。テストが10本以下の小規模プロジェクトでは、POMを導入するより先にテストを増やすことを優先しましょう。テストケースが20〜30本を超えたあたりからPOMの恩恵が大きくなります。

現場で使えるPOMのベストプラクティス5選

① ロケーターはクラス変数にまとめる

セレクター文字列はクラスの先頭にまとめて定義する。テスト内にセレクターを直書きしない。

② メソッド名はユーザーの操作を表す動詞で命名する

click_login_button()よりlogin(email, password)のように、ユーザーの行動レベルで命名する。

③ assertはテストコード側に書く

Page Object内にassertを書かない。Page Objectは操作と状態取得に専念し、判定はテストに任せる。

④ 1ページ1クラスを基本にする

ページが増えてもクラスを分けることで見通しがよくなる。ヘッダー・フッターなど共通UIは別クラスにする。

⑤ conftest.pyでfixtureを共通化する

ブラウザ起動・ページ生成などの共通セットアップはconftest.pyのfixture にまとめ、各テストで使い回す。

POM導入でよくある失敗パターン

失敗パターン 問題点と対策
Page Objectにassertを書く 操作と検証が混在して読みにくくなる。assertはテストコードに書く
1クラスに全ページをまとめる クラスが肥大化して管理できなくなる。1ページ1クラスを徹底する
テスト内にセレクターを直書き POMの意味がなくなる。セレクターは必ずPage Object内に定義する
メソッド名が実装寄りになる click_button_id_42()ではなくsubmit_order()のようにユーザー視点で命名する
Base Pageに何でも詰め込む Base Pageも肥大化する。本当に共通の処理だけを置く

🔗 関連記事もあわせてチェック

まとめ

📋 この記事のまとめ

  • POMとは「ページごとにクラスを作り、操作をメソッドとして定義する」設計パターン
  • セレクターをPage Objectに集約することで、UIが変わっても修正箇所が1か所で済む
  • テストコードから実装の詳細が消え、「何をテストしたいか」が読み取りやすくなる
  • Base Pageクラスを使うと、共通処理をさらに集約できて再利用性が上がる
  • assertはPage Objectに書かず、テストコード側に書くのが原則
  • テストケースが20〜30本を超えたあたりからPOM導入の恩恵が大きくなる

POMは一度覚えてしまえば、あらゆるテスト自動化プロジェクトで活用できる強力な武器になります。まずはログインページなど最もよく使うページから1つだけ作ってみるところから始めてみてください。

タイトルとURLをコピーしました