Skip to content

Chapter 14 · Page Object Model ​

Chapter goals: Use Page Object Model to consolidate selectors and page operations in one place, keeping the test suite maintainable even when pages change frequently.

14.1 What Problem POM Solves ​

Tests without POM look like this:

python
def test_search_bad(page):
    page.goto("https://example.com")
    page.locator('[aria-label="Enter your search term"]').fill("playwright")
    page.locator('[aria-label="Enter your search term"]').press("Enter")
    assert page.locator(".results h2").first.inner_text() == "playwright"

The problem is obvious: selectors are scattered across dozens of tests; the frontend changes one class name and you must do a global search-replace across ten places — and you'll likely miss one. POM's answer: build a class for each page (or page region), encapsulating selectors and operations inside it; tests only face business semantics.

The official docs position POM as: Page objects simplify test writing by constructing high-level APIs aligned with the application domain, and simplify maintenance by集中 selectors in one place.

14.2 Basic Implementation: Page = Class ​

The官方 standard写法:

python
# models/search.py
from playwright.sync_api import Page


class SearchPage:
    def __init__(self, page: Page):
        self.page = page
        # Selectors declared once in __init__
        self.search_term_input = page.locator('[aria-label="Enter your search term"]')

    def navigate(self):
        self.page.goto("https://bing.com")

    def search(self, text: str):
        self.search_term_input.fill(text)
        self.search_term_input.press("Enter")

The test side becomes immediately clean:

python
# tests/test_search.py
from models.search import SearchPage


def test_search(page):
    search_page = SearchPage(page)
    search_page.navigate()
    search_page.search("playwright")

Three key design points:

  1. Locators are lazily created in __init__: page.locator(...) only creates the locator description, not a DOM query; real lookup happens at action/assertion time. So selectors can be safely declared upfront even before navigation;
  2. Method names speak business language: search("playwright") not two raw fill + press calls;
  3. Page classes do not hold assertions: assertions belong to the test (see 14.4 anti-patterns).

14.3 BasePage and Componentization ​

Multiple pages share navbar, footer, and toast —收敛 them in BasePage; more reusable independent regions (navbar, cart modal) become component objects:

python
# models/base.py
from playwright.sync_api import Page, expect


class BasePage:
    def __init__(self, page: Page):
        self.page = page

    def goto(self, path: str):
        self.page.goto(f"https://shop.example.com{path}")

    def toast(self):
        return self.page.get_by_role("alert")


class Navbar:  # Component object: does not belong to any single page
    def __init__(self, page: Page):
        self.page = page
        self.cart_badge = page.get_by_test_id("cart-count")

    def open_cart(self):
        self.page.get_by_role("link", name="Cart").click()


# models/home.py — page class composes components
from models.base import BasePage
from models.navbar import Navbar


class HomePage(BasePage):
    def __init__(self, page):
        super().__init__(page)
        self.navbar = Navbar(page)          # Compose and reuse

Composition beats inheritance: pages inherit BasePage for shared utilities but compose Navbar, Footer, Toast as needed — avoiding the fragile "deep inheritance chain" anti-pattern.

14.4 Anti-Patterns to Avoid ​

Anti-patternSymptomFix
Assertions inside page methodsassert page.search("x").title == "Results"Page methods return state, tests assert
Hardcoded URLs in page classesChange env → break all pagesPass base_url from fixture or constructor
Deep inheritance chainsChanging BasePage breaks 10 subclassesPrefer composition; keep inheritance shallow
One giant page class500-line SearchPage with 20 methodsSplit into components (SearchBox, ResultsList, etc.)

14.5 POM with pytest Fixtures ​

Combine POM with the pytest fixture system so tests never touch constructors:

python
# conftest.py
import pytest
from models.search import SearchPage

@pytest.fixture
def search_page(page):
    return SearchPage(page)

# tests/test_search.py
def test_search_results(search_page):
    search_page.navigate()
    search_page.search("playwright docs")
    expect(search_page.page.get_by_text("Installation")).to_be_visible()

The test reads like a screenplay; the page class hides implementation details. When the search box selector changes, you edit one file — not twenty.

14.6 Chapter Summary ​

  • POM收敛 selectors + operations per page; tests face business semantics;
  • Locators lazily resolved in __init__; safe to declare before navigation;
  • BasePage for shared utilities; component objects for reusable regions;
  • Composition over deep inheritance; assertions belong in tests, not page classes;
  • Fixture wiring keeps test code clean: def test_x(search_page): ....

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. Why are locators created lazily in POM __init__?

2. Where should assertions belong in POM?

3. What is the recommended relationship between BasePage and component objects?

4. How does POM reduce maintenance cost when the frontend changes a class name?

🛠️ Hands-On Exercises ​

  1. Build a POM for https://demo.playwright.dev/todomvc with TodoPage, TodoItem, and FilterBar classes; write 5 tests covering add/completion/filter/clear.
  2. Introduce a selector change (rename a class attribute) in the POM; verify all tests still pass with zero test-code edits.
  3. Refactor a existing flat test (no POM) into page classes; compare line count and change-impact scope.

Proceed to Chapter 15: Parallel Execution and Sharding.