很多团队做自动化测试前两个阶段还顺风顺水一到第三阶段就开始纠结一件事测试脚本越写越多重复定位符满屏都是业务一提需求改个按钮文案测试代码跟着牵一发动全身。这时候再不引入设计模式后面就是维护地狱。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, statestate, timeouttimeout 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}**, timeoutself.default_timeout)这套封装设计思路是这样的open()统一处理跳转后的加载状态防止脚本过快进入下一步导致定位不到元素wait_for_element()把 state 参数暴露出来默认等待可见但需要等待消失时也能传hiddenfill_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[nameusername] PASSWORD_INPUT //input[namepassword] 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, timeout15000) 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(scopefunction) def login_page(page: Page) - LoginPage: login_page LoginPage(page) login_page.open_login_page() return login_page pytest.fixture(scopefunction) 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(scopefunction) 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.yamlvalid_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, encodingutf-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 和>
阅读完成 · 觉得有帮助?