第 14 章 · Page Object Model 设计模式
本章目标:用 Page Object Model 把选择器和页面操作收敛到一处,让测试套件在页面频繁改版时依然可维护。
14.1 POM 要解决什么问题
没有 POM 的测试长这样:
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"问题显而易见:选择器散落在几十个测试里,前端改一次 class 名,你要全局搜索替换十处——还可能漏改。POM 的答案是为每个页面(或页面区域)建一个类,把选择器和操作封装进去,测试只面对业务语义。
官方文档对 POM 的定位:Page objects 通过构建贴合应用领域的高层 API 简化测试编写,并通过把元素选择器集中在一处来简化维护。
14.2 基本实现:页面 = 类
官方给出的标准写法:
python
# models/search.py
from playwright.sync_api import Page
class SearchPage:
def __init__(self, page: Page):
self.page = page
# 选择器只在 __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")测试侧立刻变得干净:
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")三个关键设计点:
- Locator 在
__init__中惰性创建:page.locator(...)只是创建定位器描述,不会立刻查 DOM;真正查询发生在动作/断言执行时。因此选择器可以安全地提前声明,页面还没导航到位也不会报错; - 方法名说业务语言:
search("playwright")而不是fill + press两步裸操作; - 页面类不持有断言:断言属于测试(见 14.4 反模式)。
14.3 BasePage 与组件化
多个页面共享导航栏、页脚、Toast 提示,用 BasePage 收敛公共能力;复用性更强的独立区域(导航栏、购物车弹层)抽成组件对象:
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: # 组件对象:不属于某一个页面
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="购物车").click()
# models/home.py —— 页面类组合组件
from models.base import BasePage
from models.navbar import Navbar
class HomePage(BasePage):
def __init__(self, page):
super().__init__(page)
self.navbar = Navbar(page) # 组合复用
self.hero_title = page.get_by_role("heading", level=1)
def open(self):
self.goto("/")目录组织惯例(与 pytest 配合):
text
tests/
├── conftest.py
├── models/ # 或 pages/:页面与组件对象
│ ├── base.py
│ ├── navbar.py
│ └── home.py
├── data/ # 第 13 章的测试数据
└── test_home.py14.4 POM + pytest fixture 注入
把"构造页面对象"也交给 fixture,测试连 import 都省了,且便于统一替换(如切换环境域名):
python
# conftest.py
import pytest
from playwright.sync_api import Page
from models.home import HomePage
@pytest.fixture
def home_page(page: Page) -> HomePage:
return HomePage(page)python
# tests/test_home.py
from playwright.sync_api import expect
def test_hero_visible(home_page):
home_page.open()
expect(home_page.hero_title).to_be_visible()14.5 常见反模式
| 反模式 | 问题 | 正确做法 |
|---|---|---|
在页面类里写 expect(...) 断言 | 断言语义被埋进封装,测试失去可读性与灵活性 | 页面类只暴露定位器/动作,断言留在测试里 |
页面类里写 page.wait_for_timeout(3000) | 硬等待拖慢且不可靠 | 依靠 Playwright 自动等待与 Web-First 断言重试 |
| 一个类包装整站 | 巨型类无人敢改 | 一个页面/组件一个类,公共部分上移 BasePage |
| 页面类返回裸字符串供测试比较 | 绕过自动重试,易 flaky | 返回 Locator,让 expect 在测试侧重试断言 |
| 在页面类中做业务决策(if 登录则…) | 测试逻辑渗入封装,复用性崩塌 | 决策留在测试,页面类提供原子动作 |
一句话原则
页面类回答"怎么操作这个页面",测试回答"验证什么"。
14.6 本章小结
- POM 把选择器收敛到一处,页面改版只改一个类;
- Locator 在
__init__惰性创建,声明顺序与页面加载顺序解耦; - BasePage 管公共行为,组件对象管跨页面复用区域;
- fixture 注入页面对象让测试保持极简;断言与等待永远不进页面类。
🧪 随堂测验
点击你认为正确的选项。答错时会展示正确答案与原因解析。
1. Page Object Model 的核心收益是?
2. 在页面类 __init__ 中创建 page.locator(...),为什么页面还没加载也不会出错?
3. 关于页面类中写断言,正确的观点是?
4. 导航栏在多个页面都出现,最合理的组织方式是?
🛠️ 动手实践
- 为你熟悉的一个网站(如 GitHub)编写
LoginPage与RepoPage两个页面对象,并用 fixture 注入完成一条登录后建仓库的用例。 - 把导航栏抽成
Navbar组件,让三个不同页面的测试复用它。 - 故意在页面类里加一处
expect和一处wait_for_timeout,再重构掉,记录两处分别违反了哪条原则。
完成后进入下一章:并行执行与 Sharding。