很多团队做自动化测试,前两个阶段还顺风顺水,一到第三阶段就开始纠结一件事:测试脚本越写越多,重复定位符满屏都是,业务一提需求改个按钮文案,测试代码跟着牵一发动全身。这时候再不引入设计模式,后面就是维护地狱。Playwright 系列走到第七篇,该聊的正是这个阶段的核心动作:把 Page Object 模式真正集成进测试框架,让测试代码从“能跑”变“能养”。
这篇内容聚焦一个主题:如何用 Python + pytest + Playwright 搭一套带 Page Object 的分层测试框架。会讲清楚为什么这个阶段才做抽象、目录怎么切、BasePage 基类封装哪些能力、登录页和业务页怎么落地、怎么和 fixture 打通,最后把实战里踩过的坑一并整理出来。适合已经能独立写 Playwright 脚本、但还没系统整理过框架的同学,也适合刚接手存量测试代码想重构的人。看完照着搭,半天时间能落地。
1. 为什么到第三阶段才做 Page Object,而不是上来就封装
1.1 前两个阶段的任务是“看见问题”,不是“解决问题”
我见过不少团队,刚学 Playwright 就急着上 Page Object,结果页面还没走通,抽象出来的类全是空架子。原因很简单:你还没见过足够多真实的页面脚本,根本不知道哪些行为是公共的、哪些是个例。第二阶段说白了就是用脚本把核心业务路径跑通,这个阶段允许代码丑、允许写重复。没有这些“丑代码”做样本,第三阶段做设计就是闭门造车。
所以第三阶段的定位很清楚:在脚本已经稳定跑通的基础上,做一轮“代码治理”。治理的对象是重复的定位、散落的等待逻辑、到处复制的登录步骤。治理的手段是 Page Object——每个页面一个类,页面的元素和操作收拢到类里,测试用例只表达“用户想做什么”,不再纠缠“怎么找到那个按钮”。
1.2 不封装到底会痛在哪
举一个实际场景。某电商后台项目的测试脚本里,登录步骤被原样复制了二十多次:
page.goto("https://example.com/login") page.fill("#username", "admin") page.fill("#password", "123456") page.click("#login-btn") page.wait_for_selector(".dashboard")这串代码在二十多个文件里各存一份。某天产品把登录按钮的 id 从#login-btn改成#login-submit,你打算全局替换多少次?就算用 IDE 的全局查找替换,替换完你还得确认每个文件换的位置对不对,因为有的脚本可能已经用了#login-submit做其它断言。而页面对象模式下,改动只发生在一个地方:LoginPage类里的按钮定位符,一次改完,所有用例自动生效。
再一个痛点是断言。脚本直写时,断言散落在各个用例里,今天断言加载成功,明天断言商品标题,毫无一致性。用 Page Object 后,断言逻辑收敛到页面对象的方法里,比如assert_login_success()、assert_cart_empty(),整个团队的断言口径统一了。
我整理了一张对比表,可以直接拿去做团队分享用:
| 维度 | 脚本直写风格 | Page Object 风格 |
|---|---|---|
| 定位符存放 | 散落在各用例文件中 | 集中在页面类常量 |
| 业务步骤 | 每个用例复制一遍 | 复用页面对象方法 |
| 等待逻辑 | 各写各的,参数不统一 | 基类统一封装 |
| 元素变更影响 | 改 N 处,风险大 | 改 1 处,影响可控 |
| 用例可读性 | 满屏 locator,看不懂业务 | 方法名即业务动作 |
| 新人上手成本 | 低,但后患多 | 初期稍高,后期省事 |
有这张表在,跟同事解释为什么框架要用 POM 就轻松多了。
2. 框架目录怎么切:让每个文件都知道自己要干什么
2.1 一套清晰的目录结构
目录设计是框架的骨架,这一步偷懒,后面所有模块都会长歪。以 Python 系为例,我推荐的第三阶段目录结构长这样:
project/ ├── config/ │ └── config.py # 环境配置:base_url、超时时间、浏览器类型 ├── data/ │ ├── login_data.yaml # 测试数据(账号、密码、异常场景 │ └── user_data.json ├── pages/ │ ├── __init__.py │ ├── base_page.py # 基类:公共操作与等待封装 │ ├── login_page.py # 登录页 │ ├── dashboard_page.py # 首页/工作台 │ └── user_center_page.py # 用户中心 ├── tests/ │ ├── conftest.py # fixture:浏览器、页面对象、数据 │ ├── test_login.py │ └── test_user_center.py ├── utils/ │ ├── __init__.py │ ├── logger.py # 日志封装 │ └── screenshot.py # 截图失败处理 ├── pytest.ini # pytest 配置 └── requirements.txt有人问,为什么不是把所有页面类都塞进一个pages.py?原因很简单:单文件放五六个类,看起来省事,但每个人都在同一个文件里加代码,冲突概率指数级上升,而且文件超过五百行之后阅读体验极差。一个页面一个文件,改动互不干扰,git 历史也干净。
2.2 目录职责的边界感
目录光切出来还不够,规则必须明确。我定的一套约定在团队内跑了很久,基本没在“这个应该放哪”的问题上拌过嘴:
pages目录只放页面对象,里面可以出现等待、断言、元素定位,但绝对不放业务数据。页面上要填什么账号,那是data目录的事。tests目录只放用例,用例只调页面对象的方法,禁止直接写page.locator(...)这种操作。这条规定最容易被违反,尤其当测试时间紧的时候,人会图省事回到脚本直写。我的建议是 code review 时把这条当红线,一旦发现用例里出现裸 locator,就打回重写。utils目录放所有与页面无关的通用能力,比如日志、截图。它不依赖 Playwright,这样单测也好写。config目录管理所有可变的环境参数,绝不允许在页面类里硬编码 URL 或超时数字。
这套约定的意义在于:任何人接手代码,打开目录结构就能快速定位“改页面找 pages,改用例找 tests,改环境找 config”。团队协作时,这种可预期的结构比任何文档都重要。
3. 基类封装:把重复了五十遍的操作收进一个类
3.1 BasePage 的职责边界
BasePage 是整个框架的地基,但它最容易写过头。我见过有人把点击、输入、下拉选择全封装了一遍,结果页面类变得冗长,还失去了 Playwright 原生 API 的灵活性。封装的原则应该是:只封装“跨页面都会用到、且行为一致”的操作,个性逻辑留在各页面类里。
从第二阶段跑通的脚本里统计一下,高频重复的操作无非就这几类:等待元素出现、等待元素消失、输入文本、点击、获取元素文本、组合操作(登录这种)。把这些抽出来,就是基类的最小集:
# pages/base_page.py from typing import Optional from playwright.sync_api import Page, expect class BasePage: def __init__(self, page: Page): self.page = page # 默认超时时间,页面类里可以按需覆盖 self.default_timeout = 10000 def open(self, url: str) -> None: """跳转页面,统一做一次完整加载等待""" self.page.goto(url) self.page.wait_for_load_state("networkidle") def wait_for_element( self, selector: str, state: str = "visible", timeout: Optional[int] = None ) -> None: """统一等待入口,避免各用例自己写 wait_for_selector""" self.page.wait_for_selector( selector, state=state, timeout=timeout or self.default_timeout ) def fill_text(self, selector: str, text: str) -> None: """封装输入,遇到遮挡时能自动处理""" element = self.page.locator(selector) element.fill(text) def click_element(self, selector: str) -> None: """封装点击,点击前确保元素可见""" self.page.locator(selector).click() def get_text(self, selector: str) -> str: """获取元素文本,strip 掉首尾空白统一处理""" return self.page.locator(selector).inner_text().strip() def wait_for_url(self, url_part: str) -> None: """等待 URL 包含指定片段,常用于断言跳转""" self.page.wait_for_url(f"**{url_part}**", timeout=self.default_timeout)这套封装设计思路是这样的:open()统一处理跳转后的加载状态,防止脚本过快进入下一步导致定位不到元素;wait_for_element()把 state 参数暴露出来,默认等待可见,但需要等待消失时也能传"hidden";fill_text()和click_element()直接透传原生 locator 的调用,不做无意义的二次包装。
3.2 为什么等待要统一封装而不是各写各的
Playwright 的自动等待已经很智能了,元素定位时会自动等待到可操作状态。但自动等待不等于“等页面跳转了”,goto()之后页面里可能还有异步请求在跑,直接定位新页面的元素照样失败。第二阶段脚本里常见的做法是到处time.sleep(3),这种硬等待在好一点的网络环境下是浪费时间,在卡顿环境下又不够用。
统一封装后,超时时间这个参数有了单一治理入口。默认 10 秒写在基类里,个别慢页面可以在对应页面类的__init__里覆盖self.default_timeout = 20000。全局调优时只改基类一处,不用满项目找wait_for_selector的散装调用。
另外提一句,wait_for_url()这个方法很实用。登录后断言跳转到工作台,与其等待某个元素,不如等 URL,因为 URL 的变化往往比页面元素渲染更早,而且对页面结构变更更鲁棒。
3.3 选择器策略:少用 CSS class,多用语义化定位
定位符是 POM 里争议最多的地方。我的实践经验是,优先级从高到低排:
- role 定位:
page.get_by_role("button", name="登录"),模拟辅助技术视角,语义最清晰。 - ># pages/login_page.py from pages.base_page import BasePage class LoginPage(BasePage): # 元素定位常量集中管理 USERNAME_INPUT = "//input[@name='username']" PASSWORD_INPUT = "//input[@name='password']" LOGIN_BUTTON = "//button[contains(text(), '登 录')]" ERROR_MSG = ".error-message" SUCCESS_FLAG = "text=欢迎回来" def open_login_page(self) -> None: self.open("/login") def input_username(self, username: str) -> None: self.fill_text(self.USERNAME_INPUT, username) def input_password(self, password: str) -> None: self.fill_text(self.PASSWORD_INPUT, password) def click_login(self) -> None: self.click_element(self.LOGIN_BUTTON) def login(self, username: str, password: str) -> None: """组合操作:正常情况下用例只调这一个方法即可""" self.input_username(username) self.input_password(password) self.click_login() self.wait_for_element(self.SUCCESS_FLAG, timeout=15000) def get_error_message(self) -> str: return self.get_text(self.ERROR_MSG) def is_login_success(self) -> bool: """断言封装:返回布尔值供用例进行 expect""" return self.page.locator(self.SUCCESS_FLAG).is_visible()
这里有几个设计细节值得展开:
- 元素定位符全部定义为类常量。好处是页面类代码一眼能看完所有元素依赖,改版时对照前端代码逐行核对即可,不用翻方法体。
click_login()和login()是两个粒度的方法。前者是原子操作,后者是业务组合。有的页面类只写组合方法不写原子方法,后来别人想单独测“只输入不点击”就非要去改页面类。我的习惯是原子方法照写,组合方法基于原子方法拼装,粒度齐全,调用方按需取用。- 登录成功的等待放在
login()方法内部。这样用例里调用login()后不需要再写wait_for_selector,用例代码就是纯粹的“用户视角”叙述。
4.2 业务页面:从列表到详情,方法名就是验收标准
业务页比登录页复杂,但设计原则不变:一个公共操作对应一个方法,一个关键状态对应一个断言方法。拿用户中心页举例:
# pages/user_center_page.py from pages.base_page import BasePage class UserCenterPage(BasePage): AVATAR_IMG = ".avatar img" USERNAME_TEXT = ".user-info .name" ORDER_TAB = "text=我的订单" ORDER_LIST = ".order-list" EMPTY_ORDER_TIP = "text=暂无订单" def open_user_center(self) -> None: self.open("/user/center") def get_displayed_username(self) -> str: return self.get_text(self.USERNAME_TEXT) def switch_to_order_tab(self) -> None: self.click_element(self.ORDER_TAB) self.wait_for_element(self.ORDER_LIST) def is_order_empty(self) -> bool: return self.page.locator(self.EMPTY_ORDER_TIP).is_visible() def get_order_count(self) -> int: # 返回订单条数,供用例进行数值断言 return self.page.locator(self.ORDER_LIST).count()能看到这里的方法名基本就是验收标准的翻译:“用户能看到自己的用户名”“能切到订单页签”“空订单时有友好提示”。测试人员在写用例的时候,读的不是 locator,而是产品验收口语。这就是 Page Object 最核心的价值:让测试代码从技术细节里解放出来,直接表达业务意图。
4.3 页面对象之间怎么传递:返回新对象还是长链调用
很多教程喜欢用链式调用:
login_page.login().go_to_orders()。链式在展示页面上很帅,但实操里有一个致命问题:一旦链中间某一步失败,堆栈信息里全是页面对象方法嵌套,新手排错难到怀疑人生。而且链式调用会让用例代码变成一行超长表达式,Git diff 时改动范围连行都看不清。我更推荐的做法是显式返回页面对象:
# 正常流程:登录并进入用户中心 login_page = LoginPage(page) login_page.open_login_page() login_page.login("tester", "123456") user_center = UserCenterPage(page) user_center.open_user_center()这样每行都是一个明确的动作,失败时直接定位到具体页面方法,日志可读性高出不止一个量级。页面对象之间的流转靠测试用例来编排,而不是靠方法互相 new 对象。保持页面类之间的解耦,后续维护时不会出现“改 A 页面类导致 B 页面类出错”的恐怖故事。
5. 集成 pytest fixture:让页面对象随用例自动装配
5.1 conftest 里的 fixture 该怎么设计
页面类封装完之后,用例里如果还是
LoginPage(page)手动 new,那封装只做了一半。把页面对象的创建收进 fixture,用例代码才能真正瘦身,也能顺手解决“每个用例都要 page 实例”的重复问题。tests/conftest.py里推荐这样组织:import pytest from playwright.sync_api import Page from pages.login_page import LoginPage from pages.user_center_page import UserCenterPage @pytest.fixture(scope="function") def login_page(page: Page) -> LoginPage: login_page = LoginPage(page) login_page.open_login_page() return login_page @pytest.fixture(scope="function") def user_center_page(login_page, page) -> UserCenterPage: # 前提:已经通过登录页完成登录 login_page.login("tester", "123456") user_center = UserCenterPage(page) user_center.open_user_center() return user_center这里有一个值得说透的点:
login_page这个 fixture 在 open 之后立刻返回,并没有调用login()。为什么?因为登录动作本身可能是很多用例的“前置步骤”,但前置步骤的账号、密码在不同用例里不同。把登录动作留在 fixture 里写死,等于把数据硬编码了。我采用的模式是“打开页面交给 fixture,输入动作交给用例”。登录这类组合操作虽然封装在
LoginPage里,但如果大部分用例都需要登录,可以再来一个logged_in_pagefixture 统一完成登录:@pytest.fixture(scope="function") def logged_in_page(login_page, page) -> Page: login_page.login("tester", "123456") return page这样普通用例只要声明参数
logged_in_page,进来时就已经处在已登录状态,直接操作业务页面即可。声明式依赖让用例之间的关系一目了然。5.2 用例代码最终长什么样
框架集成完毕,一条正常路径的用例就变得非常“干净”:
# tests/test_user_center.py from playwright.sync_api import Page, expect def test_user_can_see_username(logged_in_page): user_center = UserCenterPage(logged_in_page) user_center.open_user_center() expect(user_center.get_displayed_username()).to_contain("tester") def test_empty_order_tip_shown(logged_in_page): user_center = UserCenterPage(logged_in_page) user_center.open_user_center() user_center.switch_to_order_tab() expect(user_center.is_order_empty()).to_be_truthy()注意第二条用例里用到了
expect的布尔断言。is_order_empty()返回的是布尔值,expect(True).to_be_truthy()这条链能让断言失败时输出足够清晰的定位信息。一条用例三行四行,新人进组看两条就能学会写新用例。这是衡量框架成功与否的第一标准。5.3 数据驱动:不同账号跑同一场景
框架的价值在复用,数据驱动是最典型的复用场景。把登录数据拆到
data/login_data.yaml:valid_user: username: "tester" password: "123456" expect: "登录成功" blocked_user: username: "blocked001" password: "123456" expect: "账号已被冻结"配合 pytest 的参数化:
# tests/test_login.py import pytest import yaml from pages.login_page import LoginPage with open("data/login_data.yaml", encoding="utf-8") as f: login_data = yaml.safe_load(f) @pytest.mark.parametrize( "username,password,expect_tip", [ (login_data["valid_user"]["username"], login_data["valid_user"]["password"], "登录成功"), (login_data["blocked_user"]["username"], login_data["blocked_user"]["password"], "账号已被冻结"), ], ids=["valid_user", "blocked_user"], ) def test_login_with_data(page, username, password, expect_tip): login_page = LoginPage(page) login_page.open_login_page() login_page.input_username(username) login_page.input_password(password) login_page.click_login() expect(page.locator("text=" + expect_tip)).to_be_visible()这就是 Page Object 与参数化结合的威力:处理多账号、多权限级别的场景,用例模板只写一份,数据驱动跑 N 遍。以后新增一个黑名单账号,只需要往 YAML 里加一段数据,用例代码一个字都不用动。
6. 常见问题与排查技巧实录
6.1 页面改版后定位符大面积失效,怎么把损失降到最低
页面改版是 POM 框架最常面临的考验。这里我的经验是三层防线:
第一层是定位符集中在页面类常量里。改版后拿着前端工程师的变更清单,逐页核对类常量,改的只是常量定义,用例源码不受影响。
第二层是优先使用抗变更的定位策略。前面提到的 role 和>
xAI推出Grok Bot智能体平台:用Cursor Base URL改到TaoToken实测自主任务流
/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …
PLC灌装系统实操指南:I/O映射、梯形图逻辑与联调避坑
简介:本资源是一份面向自动化、电气工程及机电一体化专业本科生的PLC课程设计与毕业设计参考文档,聚焦饮料灌装产线的软件控制系统实现,解决工业现场对逻辑控制建模、顺序功能图设计、PLC-200指令编程及系统调试等核心能力的实践训练需求。压…
Hive函数实战大全:场景分类、核心用法与避坑指南
写这篇文章只因见过太多人在 Hive 函数上栽跟头。明明离线数仓跑数、数据清洗、特征加工都离不开 Hive 内置函数,但很多同学的用法都是从别人代码里扒的,能跑就行,不知道为什么,也不知道哪些函数能做得更高效。这篇“hive函数大全…
Java体育用品商城源码深度解析:Spring Boot电商实战教学基线
简介:这是一套面向Java初学者与毕业设计学生的完整体育用品商城系统源码,覆盖用户端购物、管理员后台管理及订单全流程处理等核心业务场景,适用于课程设计、毕设选题与Web开发技能实战训练。资源包共728个文件,以110个JSP页面实现…
loadavg高但CPU低?D状态与PSI压力指标排查实战
凌晨 2 点 15 分,监控平台弹出告警:某业务实例的 1 分钟 loadavg 冲到 180,而 5 分钟和 15 分钟平均值还不到 10。切到服务器上看一眼,CPU 总体才 30% 出头,磁盘 IO 也只有零星几个百分点。第一反应是监控抽风…
GRU英文诗歌生成实战:原理、PyTorch实现与调优
开题:为什么我用GRU做英文诗歌生成,而不是直接用LSTM或Transformer前段时候帮一个学弟把毕设课题定在了英文诗歌生成上,模型框架选了GRU。这个方向听起来很文艺,但本质上是典型的时间序列生成任务,属于深度学习在自然语…