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:
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写法:
# 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:
# 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:
- 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; - Method names speak business language:
search("playwright")not two rawfill + presscalls; - 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:
# 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 reuseComposition 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-pattern | Symptom | Fix |
|---|---|---|
| Assertions inside page methods | assert page.search("x").title == "Results" | Page methods return state, tests assert |
| Hardcoded URLs in page classes | Change env → break all pages | Pass base_url from fixture or constructor |
| Deep inheritance chains | Changing BasePage breaks 10 subclasses | Prefer composition; keep inheritance shallow |
| One giant page class | 500-line SearchPage with 20 methods | Split into components (SearchBox, ResultsList, etc.) |
14.5 POM with pytest Fixtures
Combine POM with the pytest fixture system so tests never touch constructors:
# 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
- Build a POM for
https://demo.playwright.dev/todomvcwithTodoPage,TodoItem, andFilterBarclasses; write 5 tests covering add/completion/filter/clear. - Introduce a selector change (rename a
classattribute) in the POM; verify all tests still pass with zero test-code edits. - 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.