Page Object 模式与框架分层¶
PO 的目的只有一个:页面变了,只改一个文件。但很多团队引入 PO 后反而更难维护——页面类里塞断言、组件不拆、页面对象互相 import。下面给一套可以照着落地的写法。
一、三层职责¶
边界规则:
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()
注意两个设计点:
- 成功与失败返回不同对象:成功返回
HomePage,失败返回self。不要在一个方法里返回Optional让用例判断。 - 断言在用例里:页面只提供
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¶
页面一改,所有用例一起改,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 过度封装¶
这种"一个操作包一层"除了增加行数没有价值。页面方法应该对应业务动作(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,按这个顺序最省力:
- 先把定位器抽成常量(不改逻辑);
- 把重复的"登录/进入某菜单"抽成方法;
- 把方法搬到页面类,用例改为调用方法;
- 加
expect_locator与失败截图; - 最后才拆组件。
一次全改,风险高且难以回归。
十、检查清单¶
-
pages中无assert -
tests中无find_element - 所有定位器为类属性,且使用
data-testid优先 - 页面构造时有
expect_locator校验 - 跳转方法返回下一个页面对象
- 无循环 import
- 失败自动保存截图与
page_source - 组件只依赖 driver,不继承 BasePage