Skip to content

第 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")

三个关键设计点:

  1. Locator 在 __init__ 中惰性创建page.locator(...) 只是创建定位器描述,不会立刻查 DOM;真正查询发生在动作/断言执行时。因此选择器可以安全地提前声明,页面还没导航到位也不会报错;
  2. 方法名说业务语言search("playwright") 而不是 fill + press 两步裸操作;
  3. 页面类不持有断言:断言属于测试(见 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.py

14.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. 导航栏在多个页面都出现,最合理的组织方式是?

🛠️ 动手实践

  1. 为你熟悉的一个网站(如 GitHub)编写 LoginPageRepoPage 两个页面对象,并用 fixture 注入完成一条登录后建仓库的用例。
  2. 把导航栏抽成 Navbar 组件,让三个不同页面的测试复用它。
  3. 故意在页面类里加一处 expect 和一处 wait_for_timeout,再重构掉,记录两处分别违反了哪条原则。

完成后进入下一章:并行执行与 Sharding