01 · Page Object Model¶
Why not just write scripts?¶
A script like Level 1's login flow is fine for one test. Once you have dozens of tests touching the same login page, search bar, or product page, duplicating locators everywhere becomes a liability: change one CSS class on the real site and you're editing twenty test files. The Page Object Model (POM) fixes this by giving each page (or meaningful component) of the app under test a single Python class that owns its locators and the actions a user can take on it. Tests then read like user stories, not DOM archaeology.
# pages/login_page.py
from playwright.sync_api import Page
class LoginPage:
def __init__(self, page: Page):
self.page = page
self.email = page.get_by_label("Email")
self.password = page.get_by_label("Password")
self.submit = page.get_by_role("button", name="Sign in")
self.error = page.get_by_text("Invalid credentials")
def goto(self):
self.page.goto("https://example.com/login")
def login(self, email: str, password: str):
self.email.fill(email)
self.password.fill(password)
self.submit.click()
# no output — this module defines a class; nothing runs until
# a test instantiates LoginPage(page) and calls its methods
Notice locators are built once in __init__ and stored as attributes —
that's safe because a Locator is a lazy description, not a live element
reference (Level 1, Module 4), so building it before the page has even
navigated is harmless.
Using the page object in a test¶
# tests/test_login.py
from playwright.sync_api import Page, expect
from pages.login_page import LoginPage
def test_valid_login_redirects_to_dashboard(page: Page):
login = LoginPage(page)
login.goto()
login.login("user@example.com", "correct-password")
expect(page).to_have_url("https://example.com/dashboard")
def test_invalid_login_shows_error(page: Page):
login = LoginPage(page)
login.goto()
login.login("user@example.com", "wrong-password")
expect(login.error).to_be_visible()
The test file no longer knows how the login form is built — no CSS
selectors, no knowledge of which field comes first. If the login page's
markup changes, only login_page.py needs an update, and every test that
uses it is automatically fixed.
A base page for shared behavior¶
Most real apps share chrome — a nav bar, a toast/notification area, a loading spinner. Put that in a base class other page objects inherit from.
# pages/base_page.py
from playwright.sync_api import Page, expect
class BasePage:
def __init__(self, page: Page):
self.page = page
self.toast = page.locator(".toast-message")
def expect_toast(self, text: str):
expect(self.toast).to_have_text(text)
def open_nav_menu(self):
self.page.get_by_role("button", name="Menu").click()
# pages/dashboard_page.py
from playwright.sync_api import Page
from pages.base_page import BasePage
class DashboardPage(BasePage):
def __init__(self, page: Page):
super().__init__(page)
self.welcome_heading = page.get_by_role("heading", name="Welcome")
def logout(self):
self.open_nav_menu()
self.page.get_by_role("menuitem", name="Log out").click()
# no output — DashboardPage now has both its own locators
# and everything BasePage provides (toast, nav menu)
Component objects for repeated widgets¶
Not everything maps 1:1 to a URL. A product card, a data-table row, or a
modal dialog that appears on several pages deserves its own small class,
constructed from a Locator rather than a Page.
# pages/components/product_card.py
from playwright.sync_api import Locator
class ProductCard:
def __init__(self, root: Locator):
self.root = root
self.title = root.locator("h3")
self.add_to_cart = root.get_by_role("button", name="Add to cart")
def name(self) -> str:
return self.title.text_content()
# usage inside a test or page object
cards = page.locator(".product-card")
for i in range(cards.count()):
card = ProductCard(cards.nth(i))
if "Mouse" in card.name():
card.add_to_cart.click()
break
# no output — ProductCard wraps one scoped Locator per card,
# so add_to_cart.click() only ever targets that specific card
What POM is not¶
A page object should expose actions and state, not assertions about
business logic. Keep expect(...) calls in the test file (or in small
helper methods that clearly assert one thing), and keep multi-step business
workflows (e.g., "complete checkout") out of a single page's class — those
belong in a higher-level "flow" or in the test itself composing multiple
page objects. Page objects that grow into 500-line god classes doing
assertions, waiting, and orchestration are a sign the boundaries slipped.
How It Actually Works¶
Storing locators as __init__ attributes works only because of the lazy,
descriptive nature of a Locator covered in Level 1 Module 4 — each
attribute is nothing but a stored selector string (e.g. an
internal:role= or internal:has-text= expression) plus a frame reference.
No CDP query happens at construction time; the query is deferred entirely
to whichever method call finally acts on it. This is precisely why login()
can be called minutes, or several DOM re-renders, after LoginPage.__init__
ran and still finds the right element: it re-resolves against the live
accessibility tree at call time, not against whatever existed when the
object was built.
ProductCard's constructor taking a Locator (root) rather than a Page
is deliberately narrowing scope: root.locator("h3") and
root.get_by_role(...) compile to a selector chain like
internal:has-text=... >> h3, evaluated as one nested query against only
the descendants of whatever element root currently resolves to. The
resolution still happens fresh on every call — if the card at index 2 gets
removed and a new one shifts into that DOM position, cards.nth(2)'s
ProductCard wrapper transparently now describes the new card there,
because nothing about a Locator pins it to a specific DOM node identity.
Exercise¶
Using https://demoqa.com/login (a public demo site with a real, if flaky,
login form):
- Create
pages/base_page.pywith aBasePageclass holding apagereference and one shared locator you notice is common across demoqa pages (e.g. the top banner ad container, so future page objects can assert it doesn't cover the form). - Create
pages/login_page.pywith aLoginPage(BasePage)that exposesusername,password,login_buttonlocators (found viaget_by_placeholder— demoqa's inputs use placeholders) and alogin()method. - Write two tests in
tests/test_demoqa_login.py: one submitting a deliberately invalid username and asserting#name(the error banner demoqa shows) becomes visible; one leaving fields blank and asserting the fields get anis-invalidclass viaexpect(locator).to_have_class(...). - Refactor: notice both tests repeat
LoginPage(page); login.goto()— pull that into a pytest fixture (previewed here, covered fully next module) namedlogin_pageand use it in both tests instead.