Page Object Model
Your first ten browser tests are easy to write. Your hundredth is not, especially when a designer renames the "Log in" button and fifty tests break at once. The Page Object Model (POM) is the design pattern almost every UI automation team uses to stop this: each page of the application gets a class that knows its locators and actions, and tests talk only to those classes.
In this lesson you will see the problem POM solves, build page objects in plain Python that you can run, then write the same structure for real with Playwright.
The problem: locators everywhere
Without POM, every test repeats the same locators and steps:
def test_valid_login(page):
page.goto("http://localhost:8000/login.html")
page.locator("#email").fill("priya@example.com")
page.locator("#password").fill("Kart@2026")
page.locator("#login-button").click()
...
def test_wrong_password(page):
page.goto("http://localhost:8000/login.html")
page.locator("#email").fill("priya@example.com")
page.locator("#password").fill("wrong-pass")
page.locator("#login-button").click()
...If #login-button changes, you must find and fix it in every test. The tests are also hard to read: they describe clicks, not behaviour.
The pattern
With POM:
- Each page (or large component such as a header or cart drawer) becomes a class.
- The class holds the page's locators, defined once.
- Its methods are the actions a user can take there:
log_in(),search(),add_to_cart(). - A method that moves the user to another page returns the next page object.
- Tests call these methods and make assertions; they contain no locators.
When the button changes, you fix one line in LoginPage, and every test works again.
Page objects you can run
To see the structure without a browser, this example uses a FakeBrowser that imitates the Kirana Kart login page. The page objects and tests are written exactly as they would be for a real browser:
import unittest
class FakeBrowser:
"""Pretends to be a browser showing the Kirana Kart site."""
def __init__(self):
self.url = ""
self.fields = {}
self.text = {}
def open(self, url):
self.url = url
self.fields, self.text = {}, {}
def type(self, locator, value):
self.fields[locator] = value
def click(self, locator):
if locator == "#login-button":
ok = (self.fields.get("#email") == "priya@example.com"
and self.fields.get("#password") == "Kart@2026")
if ok:
self.url = "/dashboard"
self.text["h1"] = "Hello, Priya"
else:
self.text["#message"] = "Invalid email or password"
def read(self, locator):
return self.text.get(locator, "")
# ---- Page objects: one class per page, locators in one place ----
class LoginPage:
EMAIL = "#email"
PASSWORD = "#password"
SUBMIT = "#login-button"
MESSAGE = "#message"
def __init__(self, browser):
self.browser = browser
def open(self):
self.browser.open("/login")
return self
def log_in(self, email, password):
self.browser.type(self.EMAIL, email)
self.browser.type(self.PASSWORD, password)
self.browser.click(self.SUBMIT)
return DashboardPage(self.browser)
def error_message(self):
return self.browser.read(self.MESSAGE)
class DashboardPage:
HEADING = "h1"
def __init__(self, browser):
self.browser = browser
def greeting(self):
return self.browser.read(self.HEADING)
# ---- Tests talk to pages, never to locators ----
class TestLogin(unittest.TestCase):
def setUp(self):
self.login_page = LoginPage(FakeBrowser()).open()
def test_valid_login_greets_user(self):
dashboard = self.login_page.log_in("priya@example.com", "Kart@2026")
self.assertEqual(dashboard.greeting(), "Hello, Priya")
def test_wrong_password_shows_error(self):
self.login_page.log_in("priya@example.com", "nope")
self.assertEqual(self.login_page.error_message(), "Invalid email or password")
suite = unittest.TestLoader().loadTestsFromTestCase(TestLogin)
unittest.TextTestRunner(verbosity=2).run(suite)test_valid_login_greets_user (__main__.TestLogin.test_valid_login_greets_user) ... ok test_wrong_password_shows_error (__main__.TestLogin.test_wrong_password_shows_error) ... ok ---------------------------------------------------------------------- Ran 2 tests in 0.026s OK
Try it: change SUBMIT = "#login-button" to "#sign-in" in LoginPage and the FakeBrowser click line, and both tests keep working after editing just those two places. The tests themselves read like a test case: open the login page, log in, check the greeting.
Page objects with Playwright
In a real project, page objects live in their own folder:
project/
pages/
__init__.py
login_page.py
dashboard_page.py
conftest.py
test_login.pyHere is the login page with Playwright locators. It returns a DashboardPage after logging in:
# pages/login_page.py
from playwright.sync_api import Page, expect
from pages.dashboard_page import DashboardPage
class LoginPage:
def __init__(self, page: Page, base_url: str):
self.page = page
self.url = f"{base_url}/login.html"
self.email = page.get_by_label("Email")
self.password = page.get_by_label("Password")
self.submit = page.get_by_role("button", name="Log in")
self.message = page.get_by_role("alert")
def open(self):
self.page.goto(self.url)
return self
def log_in(self, email: str, password: str) -> DashboardPage:
self.email.fill(email)
self.password.fill(password)
self.submit.click()
return DashboardPage(self.page)
def expect_error(self, text: str):
expect(self.message).to_have_text(text)# pages/dashboard_page.py
from playwright.sync_api import Page, expect
class DashboardPage:
def __init__(self, page: Page):
self.page = page
self.cart_count = page.get_by_test_id("cart-count")
def expect_greeting(self, name: str):
expect(self.page.get_by_role("heading", name=f"Hello, {name}")).to_be_visible()A fixture in conftest.py builds the page object, so the tests become very short:
# conftest.py
import pytest
from pages.login_page import LoginPage
@pytest.fixture
def login_page(page):
return LoginPage(page, "http://localhost:8000").open()# test_login.py
from playwright.sync_api import expect
def test_valid_login(login_page):
dashboard = login_page.log_in("priya@example.com", "Kart@2026")
dashboard.expect_greeting("Priya")
expect(dashboard.cart_count).to_have_text("Cart: 0 items")
def test_wrong_password(login_page):
login_page.log_in("priya@example.com", "wrong-pass")
login_page.expect_error("Invalid email or password")Running pytest -q on this project prints 2 passed. Playwright locators are lazy: creating page.get_by_label("Email") in the constructor does not search the page yet; the search happens when you call fill() or expect(). That is why it is safe to define them before the page has loaded.
Where should assertions live?
Teams disagree, and both styles work if you are consistent:
- Assertions in tests only (the classic rule): page objects return values or locators, and tests decide what is correct. This keeps page objects reusable.
- Small assertion helpers in page objects, like
expect_error()above, which avoid repeating the same check. Keep them generic: the expected value comes from the test.
Never hide the decision of a test inside a page object. A reader should see what each test checks without opening other files.
Common mistakes
- Locators in tests. If a test file contains
#emailor an XPath, the pattern has broken down. - God pages. One
AppPageclass with 200 methods is as hard to maintain as no pattern. Split by page and by component. - Page objects that make decisions.
log_in()should not check whether the login should succeed; the test decides. - Sleeps inside page methods. Use Playwright auto-waiting or explicit waits, never fixed sleeps.
Key points
- The Page Object Model gives each page or component a class holding its locators and actions.
- Tests call page methods and contain no locators, so a UI change is fixed in one place.
- Methods that navigate return the next page object, which keeps test steps readable.
- Build page objects in a fixture so tests start from a ready page.
- Keep test decisions in tests; keep page objects focused, small and free of sleeps.
Exercise
Add a CartPage to the runnable example. Give FakeBrowser a way to add items (for example a cart list and a click on "#add-notebook" that appends "Notebook"), give DashboardPage a method add_notebook() that returns a CartPage, and give CartPage a method item_count(). Write a test that logs in, adds one notebook and checks that the cart holds 1 item.
Show solution
# In FakeBrowser.__init__ add: self.cart = []
# In FakeBrowser.click add:
# if locator == "#add-notebook":
# self.cart.append("Notebook")
class DashboardPage:
HEADING = "h1"
ADD_NOTEBOOK = "#add-notebook"
def __init__(self, browser):
self.browser = browser
def greeting(self):
return self.browser.read(self.HEADING)
def add_notebook(self):
self.browser.click(self.ADD_NOTEBOOK)
return CartPage(self.browser)
class CartPage:
def __init__(self, browser):
self.browser = browser
def item_count(self):
return len(self.browser.cart)
# In TestLogin:
def test_add_notebook_to_cart(self):
dashboard = self.login_page.log_in("priya@example.com", "Kart@2026")
cart = dashboard.add_notebook()
self.assertEqual(cart.item_count(), 1)The test reads as a user journey (log in, add a notebook, check the cart) and never mentions #add-notebook. If the button's ID changes, only DashboardPage.ADD_NOTEBOOK needs editing.
Quick Quiz
1. What is the main benefit of the Page Object Model?
POM centralises locators and actions, which makes suites easier to maintain.
2. What should LoginPage.log_in() return after a successful login?
Returning the next page object lets tests chain steps naturally.
3. Which line should NOT appear in a test file that follows POM?
Locators belong in page objects; tests call page methods.
4. Why is it safe to create Playwright locators in a page object's constructor?
A Playwright locator is a description; it finds elements at the moment of an action or assertion.
Learn Software Testing with a mentor
Free lessons get you started. Our Software Testing training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
