By chapter 5, your tests probably look like this: a sequence of page.get_by_label(...), page.get_by_role(...), page.click(...) calls inline in every test function. That works for ten tests. At a hundred, every locator change ripples through dozens of files; at five hundred, the suite is unmaintainable. The Page Object Model (POM) is the pattern that solves it — encapsulate every page's locators and actions in a Python class, give tests a high-level API to drive that class, and changes to the UI become changes to one file. The TypeScript version of this lesson uses TS classes; the Python version uses dataclass-friendly classes with type hints, and the result reads even cleaner.
What a page object actually is
A page object is a Python class that:
- Holds a
page: Pagereference passed in via the constructor. - Exposes the page's locators as instance attributes — pre-built
Locatorobjects, not raw selector strings. - Exposes high-level actions as methods —
login(email, password),add_to_cart(product_name),submit_form(). - Knows nothing about pytest fixtures or assertions — those belong in test code.
The shape:
from playwright.sync_api import Page, Locator, expect
class LoginPage:
def __init__(self, page: Page):
self.page = page
# Locators — defined once, resolved lazily on each use
self.email_input: Locator = page.get_by_label("Email")
self.password_input: Locator = page.get_by_label("Password")
self.submit_button: Locator = page.get_by_role("button", name="Sign in")
self.error_message: Locator = page.get_by_test_id("error-message")
def goto(self):
self.page.goto("/login")
def login(self, email: str, password: str):
self.email_input.fill(email)
self.password_input.fill(password)
self.submit_button.click()
def expect_error(self, message: str):
expect(self.error_message).to_contain_text(message)The locators are lazy — they're recipes, not snapshots. Defining them in __init__ is safe even before navigation; they don't query the DOM until you act on them. This is the same lazy-locator behaviour from chapter 2.
Using a page object in a test
The test code becomes high-level and reads like a recipe:
def test_successful_login(page: Page):
login_page = LoginPage(page)
login_page.goto()
login_page.login("alice@test.com", "password123")
expect(page).to_have_url("/dashboard")Compare to inline:
def test_successful_login(page: Page):
page.goto("/login")
page.get_by_label("Email").fill("alice@test.com")
page.get_by_label("Password").fill("password123")
page.get_by_role("button", name="Sign in").click()
expect(page).to_have_url("/dashboard")Both work. The first reads as what the user does — go to login, log in. The second reads as how Playwright drives the DOM. When the email field's label changes from "Email" to "Email address", you change one line in LoginPage and every test that uses it keeps working.
Page objects as fixtures — the cleaner shape
Wrap each page object in a fixture and the test gets even tighter:
import pytest
@pytest.fixture
def login_page(page: Page) -> LoginPage:
return LoginPage(page)
def test_login(login_page: LoginPage):
login_page.goto()
login_page.login("alice@test.com", "password123")
expect(login_page.page).to_have_url("/dashboard")Tests that need the login page take it as a parameter; tests that don't, don't. The -> LoginPage return type lights up IDE autocomplete on every login_page.<method> call.
A BasePage for shared behaviour
When several page objects share helpers — navigation, title-getting, common waits — pull them into a base class:
class BasePage:
def __init__(self, page: Page):
self.page = page
def navigate(self, path: str):
self.page.goto(path)
def get_title(self) -> str:
return self.page.title()
def wait_for_url(self, url: str):
self.page.wait_for_url(url)
class ProductPage(BasePage):
def __init__(self, page: Page):
super().__init__(page)
self.search_input = page.get_by_placeholder("Search")
self.product_cards = page.get_by_test_id("product-card")
self.add_to_cart_button = page.get_by_role("button", name="Add to cart")
def search(self, query: str):
self.search_input.fill(query)
self.search_input.press("Enter")
def add_first_to_cart(self):
self.product_cards.first.locator(":scope >> ").get_by_role(
"button", name="Add to cart"
).click()Subclasses inherit self.page, navigate, and get_title for free. Keep the base class small — five-or-fewer helpers — or it becomes a god class that everything depends on.
Project structure
A real-world POM project layout:
pages/
├── __init__.py
├── base_page.py ← BasePage class
├── login_page.py ← LoginPage class
├── product_page.py ← ProductPage class
├── checkout_page.py ← CheckoutPage class
└── components/
├── header.py ← shared Header component class
└── footer.py ← shared Footer component class
Notes:
__init__.pycan re-export common classes so test files import once:from pages import LoginPage, ProductPage.components/is for cross-cutting widgets (header, footer, sidebar) that appear on multiple pages. Page classes compose them:self.header = Header(page).- One file per page is the rule of thumb. Splitting one massive
pages.pyis the most common refactor on a growing suite.
How POM flows through a test
Five layers, each with one responsibility. Tests read like recipes, fixtures wire dependencies, page objects encapsulate UI behaviour, locators describe queries, and the DOM is what they ultimately drive.
Component objects — the cross-cutting case
The header appears on every page; you don't want to redefine it in LoginPage, ProductPage, and CheckoutPage. Build a header component once and compose it in:
class Header:
def __init__(self, page: Page):
self.page = page
self.cart_link = page.get_by_role("link", name="Cart")
self.search_input = page.get_by_placeholder("Search products")
self.user_menu = page.get_by_test_id("user-menu")
def open_cart(self):
self.cart_link.click()
def search(self, query: str):
self.search_input.fill(query)
self.search_input.press("Enter")
class ProductPage(BasePage):
def __init__(self, page: Page):
super().__init__(page)
self.header = Header(page)
self.product_cards = page.get_by_test_id("product-card")Now product_page.header.search("laptop") works from any page that includes the header. Same Header class, reused everywhere.
Coming from Playwright TypeScript?
The TypeScript course's POM lesson uses TS classes with the same shape. The mappings:
- TS
class LoginPage { constructor(public readonly page: Page) {} }→ Pythonclass LoginPage: def __init__(self, page: Page): self.page = page - TS
private readonly emailInput: Locator→ Pythonself.email_input: Locator(Python has no realprivate, but a leading underscore signals "internal" by convention) - TS
await loginPage.login(...)→ Pythonlogin_page.login(...)(no await — sync API) - TS
extends BasePage→ Pythonclass LoginPage(BasePage):
The Python version is slightly less verbose because you don't need TypeScript's access modifiers (public, private, readonly). The trade-off is that Python doesn't enforce immutability — discipline replaces the type system. For a small QA team this is fine; for a 50-engineer suite, type checkers like mypy fill the gap.
⚠️ Common mistakes
- Returning Locators from action methods. A method like
def search(self, q): return self.search_inputblurs the line between action and locator. Tests then start chaining off the return value (page_obj.search("x").click()) which leaks page-object internals back into test code. Actions should returnNoneor another page object (for navigation flows:def login(...) -> DashboardPage). - Putting assertions inside page objects.
def login(...): self.submit.click(); expect(self.page).to_have_url("/dashboard")couples the page object to one expected outcome. The test that wants to verify a failed login can't reuse the method. Keepexpect(...)calls in test code; the page object just performs the action. - Defining locators outside
__init__.self.email_input = page.get_by_label(...)works inside__init__but breaks if you do it as a module-level constant, because there's nopageto call methods on yet. Always build locators in the constructor, never at import time.
🎯 Practice task
Refactor the chapter 4 tests into a POM. 30-40 minutes.
-
In your project, create the directory:
pages/ ├── __init__.py ├── base_page.py ├── login_page.py └── inventory_page.py -
pages/base_page.py:from playwright.sync_api import Page class BasePage: def __init__(self, page: Page): self.page = page def navigate(self, path: str): self.page.goto(path) -
pages/login_page.py:from playwright.sync_api import Page, Locator, expect from pages.base_page import BasePage class LoginPage(BasePage): def __init__(self, page: Page): super().__init__(page) self.username_input: Locator = page.get_by_placeholder("Username") self.password_input: Locator = page.get_by_placeholder("Password") self.login_button: Locator = page.get_by_role("button", name="Login") self.error_message: Locator = page.get_by_test_id("error") def goto(self): self.navigate("/") def login(self, username: str, password: str): self.username_input.fill(username) self.password_input.fill(password) self.login_button.click() def expect_error(self, text: str): expect(self.error_message).to_contain_text(text) -
pages/inventory_page.py:from playwright.sync_api import Page, Locator, expect from pages.base_page import BasePage class InventoryPage(BasePage): def __init__(self, page: Page): super().__init__(page) self.product_cards: Locator = page.locator(".inventory_item") self.cart_badge: Locator = page.locator(".shopping_cart_badge") def add_first_to_cart(self): self.product_cards.first.get_by_role("button", name="Add to cart").click() -
In
tests/conftest.py, expose them as fixtures:import pytest from playwright.sync_api import Page from pages.login_page import LoginPage from pages.inventory_page import InventoryPage @pytest.fixture def login_page(page: Page) -> LoginPage: return LoginPage(page) @pytest.fixture def inventory_page(page: Page) -> InventoryPage: return InventoryPage(page) -
Write
tests/test_pom.py:from playwright.sync_api import expect from pages.login_page import LoginPage from pages.inventory_page import InventoryPage def test_login_then_add_to_cart(login_page: LoginPage, inventory_page: InventoryPage): login_page.goto() login_page.login("standard_user", "secret_sauce") inventory_page.add_first_to_cart() expect(inventory_page.cart_badge).to_have_text("1") def test_invalid_login_shows_error(login_page: LoginPage): login_page.goto() login_page.login("locked_out_user", "secret_sauce") login_page.expect_error("locked out") -
Run with
pytest tests/test_pom.py -v. Both pass. -
Stretch: Build a
Headercomponent class with the cart icon, search input, and user menu. RefactorInventoryPageto composeself.header = Header(page)in its constructor. Add a test that usesinventory_page.header.search("laptop").
POM is the architectural pattern that turns a Playwright test suite from "scripts" to "code." The next lesson covers multi-browser and mobile emulation — same tests, different rendering surfaces, configured in conftest.