跳转至

Page Object 模式与框架分层

PO 的目的只有一个:页面变了,只改一个文件。但很多团队引入 PO 后反而更难维护——页面类里塞断言、组件不拆、页面对象互相 import。下面给一套可以照着落地的写法。

一、三层职责

core/     基础设施:驱动创建、配置、日志、等待、截图
pages/    页面与组件:定位器、原子操作、页面状态
tests/    用例:业务编排 + 断言

边界规则:

  • pages 里不写断言,只提供状态(is_error_shown()、order_no())。
  • tests 里不出现 find_element。
  • core 不 import 任何 pages。

违反这三条,PO 就会退化成"多一层函数调用"。

二、core:驱动与配置

# core/config.py
import os
from dataclasses import dataclass

@dataclass(frozen=True)
class Config:
    base_url: str = os.getenv("QALAB_BASE_URL", "http://127.0.0.1:8000")
    headless: bool = os.getenv("QALAB_HEADLESS", "1") == "1"
    timeout: int = int(os.getenv("QALAB_TIMEOUT", "10"))

settings = Config()
# core/driver.py
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from core.config import settings

def create_driver():
    options = webdriver.ChromeOptions()
    if settings.headless:
        options.add_argument("--headless=new")
    options.add_argument("--no-sandbox")
    options.add_argument("--disable-dev-shm-usage")
    options.add_argument("--window-size=1440,900")
    driver = webdriver.Chrome(service=Service(), options=options)
    driver.implicitly_wait(0)          # 只用显式等待
    driver.set_page_load_timeout(30)
    return driver

本地与 CI 用同一个 create_driver(),差异只通过环境变量注入,避免"本地能跑、CI 白屏"。

三、BasePage:把等待和重试收敛到一个地方

# pages/base_page.py
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import (
    StaleElementReferenceException, ElementClickInterceptedException,
)
from core.config import settings

class BasePage:
    def __init__(self, driver, expect_locator=None):
        self.driver = driver
        self.wait = WebDriverWait(
            driver, settings.timeout, poll_frequency=0.2,
            ignored_exceptions=[StaleElementReferenceException])
        if expect_locator:
            # 页面对象创建即校验"我到对页面了",避免后续报错难以定位
            self.wait.until(EC.presence_of_element_located(expect_locator))

    def find(self, locator):
        return self.wait.until(EC.visibility_of_element_located(locator))

    def click(self, locator):
        for attempt in range(3):
            try:
                el = self.wait.until(EC.element_to_be_clickable(locator))
                self.driver.execute_script(
                    "arguments[0].scrollIntoView({block:'center'})", el)
                el.click()
                return self
            except (StaleElementReferenceException,
                    ElementClickInterceptedException):
                if attempt == 2:
                    raise

    def type(self, locator, text):
        el = self.find(locator)
        el.clear()
        el.send_keys(text)
        return self

    def text_of(self, locator):
        return self.find(locator).text.strip()

    def is_visible(self, locator, timeout=3):
        try:
            WebDriverWait(self.driver, timeout).until(
                EC.visibility_of_element_located(locator))
            return True
        except Exception:
            return False

expect_locator 是低成本的保险:登录页构造时等 #username 出现,失败信息会明确指出"页面没加载",而不是在后面的 send_keys 上抛出难懂的 TimeoutException。

四、页面对象:定位器集中 + 返回下一页

# pages/login_page.py
from selenium.webdriver.common.by import By
from pages.base_page import BasePage

class LoginPage(BasePage):
    USERNAME = (By.CSS_SELECTOR, "[data-testid='login-username']")
    PASSWORD = (By.CSS_SELECTOR, "[data-testid='login-password']")
    SUBMIT   = (By.CSS_SELECTOR, "[data-testid='login-submit']")
    ERROR    = (By.CSS_SELECTOR, ".ant-form-item-explain-error")

    def __init__(self, driver):
        super().__init__(driver, expect_locator=self.USERNAME)

    def open(self):
        self.driver.get(f"{settings.base_url}/login")
        return self

    def login(self, username, password):
        self.type(self.USERNAME, username)
        self.type(self.PASSWORD, password)
        self.click(self.SUBMIT)
        return HomePage(self.driver)

    def login_expect_failure(self, username, password):
        self.type(self.USERNAME, username)
        self.type(self.PASSWORD, password)
        self.click(self.SUBMIT)
        return self

    def error_text(self):
        return self.text_of(self.ERROR)
# pages/home_page.py
class HomePage(BasePage):
    WELCOME = (By.CSS_SELECTOR, "[data-testid='welcome']")

    def __init__(self, driver):
        super().__init__(driver, expect_locator=self.WELCOME)

    def welcome_text(self):
        return self.text_of(self.WELCOME)

用例层:

def test_login_success(driver):
    home = LoginPage(driver).open().login("qa_user", "Passw0rd!")
    assert home.welcome_text() == "欢迎回来"

def test_login_wrong_password(driver):
    page = LoginPage(driver).open().login_expect_failure("qa_user", "wrong")
    assert "密码错误" in page.error_text()

注意两个设计点:

  1. 成功与失败返回不同对象:成功返回 HomePage,失败返回 self。不要在一个方法里返回 Optional 让用例判断。
  2. 断言在用例里:页面只提供 error_text(),判断"是否包含密码错误"属于业务预期。

五、组件:别让页面类长到几百行

导航栏、弹窗、表格在多个页面复用,抽成 Component:

# pages/components/nav_bar.py
from selenium.webdriver.common.by import By

class NavBar:
    ROOT     = (By.CSS_SELECTOR, "header[data-testid='nav']")
    USER     = (By.CSS_SELECTOR, "[data-testid='nav-user']")
    LOGOUT   = (By.CSS_SELECTOR, "[data-testid='nav-logout']")

    def __init__(self, driver):
        self.driver = driver

    def current_user(self):
        return self.driver.find_element(*self.USER).text.strip()

    def logout(self):
        self.driver.find_element(*self.USER).click()
        self.driver.find_element(*self.LOGOUT).click()

组件不继承 BasePage(它没有独立 URL),只依赖 driver,保持轻量。

六、失败自动截图:挂在 pytest 钩子上

# tests/conftest.py
import os, datetime, pytest
from core.driver import create_driver

@pytest.fixture
def driver():
    d = create_driver()
    yield d
    d.quit()

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
    outcome = yield
    report = outcome.get_result()
    if report.when == "call" and report.failed:
        d = item.funcargs.get("driver")
        if d:
            os.makedirs("artifacts", exist_ok=True)
            stamp = datetime.datetime.now().strftime("%H%M%S")
            name = f"artifacts/{item.name}_{stamp}"
            d.save_screenshot(f"{name}.png")
            with open(f"{name}.html", "w", encoding="utf-8") as f:
                f.write(d.page_source)

截图 + page_source 一起存,才算完整证据:截图告诉你"看到什么",HTML 告诉你"DOM 是什么"。

七、五种把 PO 写坏的方式

7.1 页面类里写断言和业务分支

# 反模式
def login(self, user, pwd):
    ...
    assert "欢迎" in self.driver.page_source     # 页面类不该管预期
    if self.is_admin():
        return AdminPage(...)
    return HomePage(...)

后果:同一个页面方法在不同用例里期望不同,断言被写死,用例无法表达差异。

7.2 用例里直接 find_element

def test_order(driver):
    driver.find_element(By.ID, "buy").click()    # 定位器泄漏到用例

页面一改,所有用例一起改,PO 失效。

7.3 页面对象互相 import 形成环

login_page import home_page,home_page 又 import login_page(因为退出登录要回登录页)。Python 会报 ImportError: cannot import name ... (most likely due to a circular import)。

解法:跳转方法内延迟 import,或把跳转交给用例编排:

def logout(self):
    from pages.login_page import LoginPage     # 函数内 import,打破环
    self.click(self.LOGOUT)
    return LoginPage(self.driver)

7.4 过度封装

def click_login_button(self):
    self.click(self.SUBMIT)

这种"一个操作包一层"除了增加行数没有价值。页面方法应该对应业务动作(login、submit_order),不是单个元素操作。

7.5 定位器散落

self.driver.find_element(By.ID, "username")   # 方法里临时写
self.driver.find_element(By.XPATH, "//input[@id='username']")  # 另一处又换写法

同一个元素两种定位,改一处漏一处。定位器必须是类属性,且全项目唯一。

八、什么时候不该用 PO

  • 接口自动化:没有页面,用 ApiClient + 资源模型。
  • 一次性脚本、爬虫式操作:直接写更省事。
  • 单页面工具:一个文件足够,强行分层反而增加跳转成本。

PO 的收益来自"同一页面被多个用例复用",复用度低时不必强上。

九、迁移路径

已有脚本想改成 PO,按这个顺序最省力:

  1. 先把定位器抽成常量(不改逻辑);
  2. 把重复的"登录/进入某菜单"抽成方法;
  3. 把方法搬到页面类,用例改为调用方法;
  4. 加 expect_locator 与失败截图;
  5. 最后才拆组件。

一次全改,风险高且难以回归。

十、检查清单

  • pages 中无 assert
  • tests 中无 find_element
  • 所有定位器为类属性,且使用 data-testid 优先
  • 页面构造时有 expect_locator 校验
  • 跳转方法返回下一个页面对象
  • 无循环 import
  • 失败自动保存截图与 page_source
  • 组件只依赖 driver,不继承 BasePage

相关阅读