Page Object Model in Python

9 min read

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:

  1. Holds a page: Page reference passed in via the constructor.
  2. Exposes the page's locators as instance attributes — pre-built Locator objects, not raw selector strings.
  3. Exposes high-level actions as methodslogin(email, password), add_to_cart(product_name), submit_form().
  4. 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__.py can 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.py is 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) {} } → Python class LoginPage: def __init__(self, page: Page): self.page = page
  • TS private readonly emailInput: Locator → Python self.email_input: Locator (Python has no real private, but a leading underscore signals "internal" by convention)
  • TS await loginPage.login(...) → Python login_page.login(...) (no await — sync API)
  • TS extends BasePage → Python class 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_input blurs 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 return None or 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. Keep expect(...) 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 no page to 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.

  1. In your project, create the directory:

    pages/
    ├── __init__.py
    ├── base_page.py
    ├── login_page.py
    └── inventory_page.py
    
  2. 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)
  3. 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)
  4. 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()
  5. 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)
  6. 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")
  7. Run with pytest tests/test_pom.py -v. Both pass.

  8. Stretch: Build a Header component class with the cart icon, search input, and user menu. Refactor InventoryPage to compose self.header = Header(page) in its constructor. Add a test that uses inventory_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.

// tip to track lessons you complete and pick up where you left off across devices.