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 |
|---|---|
|
|
⚠️ 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 |
|
③ 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
-
⑤ 5 Design Principles to Avoid Test Automation Failure
→ Understand the full picture of design principles, including POM -
⑦ How to Think About ROI in Test Automation
→ Lower maintenance costs with POM to maximize your ROI -
④ Selenium vs Playwright vs pytest | How to Choose Your Python Test Automation Tool
→ A tool selection guide to pair with POM
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.
