What Is Page Object Model? A Beginner’s Guide to Maintainable Test Automation with Playwright & Python

The Page Object Model (POM) is the most widely adopted design pattern in test automation, dramatically improving maintainability, reusability, and readability of your test code.

📌 Who This Article Is For

  • Beginners who just started test automation and aren’t sure how to organize their code
  • Engineers whose scripts are becoming harder to maintain as the test count grows
  • Those who have heard of Page Object Model but aren’t sure how to implement it
  • Python engineers who want to use POM practically with Playwright or Selenium

✅ What You’ll Learn

  • The concept of Page Object Model and why it matters
  • A side-by-side comparison of code with and without POM
  • How to implement POM concretely with Playwright (Python)
  • Best practices you can apply in real-world projects

👤
About the Author: QA Engineer with hands-on experience in test automation using Selenium, Playwright, Python, and pytest. Having introduced Page Object Model in real projects to drastically cut maintenance costs, this article explains POM at a level where even beginners can put it into practice. Code samples are also available on GitHub.

📌 Key Takeaway

Page Object Model is a design pattern where “you create a class for each page and define its operations as methods.” When the UI changes, you only need to update one place — the Page Object class — leaving your test code untouched. If you’re running test automation long-term, POM is essential.

When you first start automating tests, simple scripts are enough. But as test cases multiply and UI changes roll in, you can easily find yourself updating dozens of places in your code every time something changes. “I automated everything, but now maintenance is a nightmare…” — this is almost always caused by writing test code without a design pattern. In this article, we’ll walk through the Page Object Model (POM) — the solution to that problem — from concept to implementation.

What Is Page Object Model?

Page Object Model (POM) is a design pattern where each page of a web application is represented as a class, and that page’s interactions are encapsulated as methods. Proposed by the Selenium community in 2009, it is now the standard design approach used across many test frameworks including Selenium, Playwright, and Cypress.

📐 POM Core Concept

🖥️ Page Object

A class that bundles each page’s elements (locators) and operations (methods)

🧪 Test Code

Just calls Page Object methods — no need to know the HTML details

✅ Result

When the UI changes, only the Page Object needs updating — test code stays untouched

💡 Key Point: The essence of POM is separating “test intent (what you want to test)” from “implementation details (which HTML to interact with).” With this separation, changes to either side have minimal impact on the other.

Code Without POM: What Goes Wrong?

Let’s look at what test code looks like without POM. It seems simple at first, but serious problems emerge as your test suite grows.

❌ Without POM: The Common Approach

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

        # Navigate to login page
        page.goto("https://example.com/login")

        # Enter email address
        page.fill("#email", "user@example.com")

        # Enter password
        page.fill("#password", "secret123")

        # Click login button
        page.click("#login-btn")

        # Verify the dashboard is visible
        assert page.is_visible("#dashboard")
        browser.close()

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

        # The same selectors (#email, #password, #login-btn) appear again!
        page.goto("https://example.com/login")
        page.fill("#email", "wrong@example.com")
        page.fill("#password", "wrongpass")
        page.click("#login-btn")

        # Verify error message
        assert page.is_visible("#error-message")
        browser.close()

⚠️ Problems With This Approach

  • Selectors (like #email) are scattered across multiple tests. If the HTML changes, every test needs to be updated
  • Code is duplicated (violates the DRY principle). With 100 tests, you’d need to update 100 places
  • Test code describes “how to interact” rather than “what is being tested.” Readability suffers
  • Browser operation details are mixed into tests, making test intent hard to read

Code With POM: What Changes?

Let’s rewrite the same tests using POM. The code is slightly longer upfront, but the long-term maintenance cost drops dramatically.

① Create the Page Object Class

pages/login_page.py

class LoginPage:
    """Page Object class for the Login page"""

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

        # Locators are centralized here (← only update this if HTML changes)
        self.email_input    = "#email"
        self.password_input = "#password"
        self.login_button   = "#login-btn"
        self.dashboard      = "#dashboard"
        self.error_message  = "#error-message"

    def navigate(self):
        """Navigate to the login page"""
        self.page.goto("https://example.com/login")

    def login(self, email: str, password: str):
        """Encapsulate the full login action into a single method"""
        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:
        """Check if the dashboard is displayed"""
        return self.page.is_visible(self.dashboard)

    def is_error_visible(self) -> bool:
        """Check if the error message is displayed"""
        return self.page.is_visible(self.error_message)

② Test Code Becomes Clean and Simple

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):
    # ← No selectors anywhere. Only the test intent is expressed.
    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()

✅ Improvements Gained by Switching to POM

  • Selectors are centralized in the LoginPage class. When HTML changes, only one place needs updating
  • Test code reads like natural language. Just calling login_page.login() makes the intent immediately clear
  • Adding new tests is easy. Just call the existing Page Object methods

Recommended Directory Structure

Here is a simple and practical directory structure for implementing POM. It scales cleanly as your number of pages grows.

project/
├── pages/                    # Directory for all Page Object classes
│   ├── base_page.py          # Common logic shared across all pages
│   ├── login_page.py         # Login page
│   ├── dashboard_page.py     # Dashboard page
│   └── profile_page.py       # Profile page
├── tests/                    # Directory for all test code
│   ├── conftest.py           # Shared fixture definitions (browser, page, etc.)
│   ├── test_login.py         # Login-related tests
│   ├── test_dashboard.py     # Dashboard-related tests
│   └── test_profile.py       # Profile-related tests
├── pytest.ini                # pytest configuration file
└── requirements.txt          # Dependency list

Advanced: Centralizing Common Logic with a Base Page Class

Logic shared across multiple Page Objects — such as waiting for elements, scrolling, and taking screenshots — can be centralized in a Base Page class for even greater reusability.

pages/base_page.py

class BasePage:
    """Base class for all Page Objects: centralizes common logic"""

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

    def wait_for_element(self, selector: str, timeout: int = 5000):
        """Wait until an element is visible"""
        self.page.wait_for_selector(selector, timeout=timeout)

    def take_screenshot(self, filename: str):
        """Capture a screenshot"""
        self.page.screenshot(path=f"screenshots/{filename}.png")

    def scroll_to_bottom(self):
        """Scroll to the bottom of the page"""
        self.page.evaluate("window.scrollTo(0, document.body.scrollHeight)")


class LoginPage(BasePage):
    """LoginPage inheriting from BasePage"""

    def __init__(self, page):
        super().__init__(page)   # ← Call BasePage's initializer
        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)  # ← Uses the inherited common method
        self.page.fill(self.email_input, email)
        self.page.fill(self.password_input, password)
        self.page.click(self.login_button)

💡 Pro Tip: Put logic in Base Page that you’d likely use on any page — wait helpers, screenshot capture, login state checks, etc. Having common logic in one place makes it much easier to update and improve later.

POM: Pros and Cons

✅ Advantages ⚠️ Disadvantages & Caveats
  • UI changes only require updating one place
  • Test code becomes more readable
  • Code reusability improves significantly
  • Adding new tests becomes straightforward
  • Easier to divide work across a team
  • Initial setup takes a bit of effort
  • Can feel like over-engineering when the test count is small
  • Page Objects can become bloated without careful design
  • Benefits are halved if the whole team doesn’t understand POM

⚠️ Caution: POM is not a silver bullet. For small projects with fewer than 10 tests, focus on writing more tests before introducing POM. The real benefits kick in once you have around 20–30 test cases.

5 POM Best Practices for Real-World Projects

① Group all locators as class variables

Define all selector strings at the top of the class. Never hardcode selectors inside test methods.

② Name methods from the user’s perspective

Prefer login(email, password) over click_login_button(). Name methods at the level of user actions, not UI operations.

③ Keep assertions in the test code, not the Page Object

Don’t write asserts inside Page Objects. Page Objects handle operations and state retrieval — leave all assertions to the test code.

④ One class per page as the default rule

As pages multiply, keeping one class per page maintains clarity. Shared UI like headers and footers should be separate classes.

⑤ Centralize fixtures in conftest.py

Common setup logic like browser launch and page creation should live in conftest.py fixtures so every test can reuse them without repetition.

Common Mistakes When Introducing POM

Mistake Problem & Fix
Writing asserts inside Page Objects Mixes operations and assertions, hurting readability. Keep all asserts in the test code
Putting all pages in one class The class becomes unmanageable. Follow the one-class-per-page rule strictly
Hardcoding selectors in test files This defeats the whole purpose of POM. Always define selectors inside the Page Object
Naming methods after implementation details Use submit_order() not click_button_id_42(). Name from the user’s perspective
Stuffing everything into Base Page Base Page bloats too. Only add logic that is truly shared across all pages

🔗 Related Articles

Summary

📋 Key Takeaways from This Article

  • POM is a design pattern where “you create a class per page and define operations as methods”
  • By centralizing selectors in the Page Object, UI changes only require updating one place
  • Implementation details disappear from test code, making “what is being tested” easy to understand at a glance
  • Using a Base Page class lets you centralize common logic further and improves reusability
  • Assertions belong in the test code, not inside Page Objects
  • POM’s benefits become significant once you have around 20–30 test cases

Once you’ve learned POM, it becomes a powerful tool you can apply across any test automation project. Start small — create just one Page Object for your most-used page, like the login page — and build from there.

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