UI自动化测试维护成本高?一文搞懂Page Object模式落地实践
2026/9/19 8:45:48 网站建设 项目流程

1. 为什么你写的UI自动化脚本总是维护到崩溃

先聊个场景。很多人刚接触Web UI自动化时,热情极高,Selenium装好、ChromeDriver配好,一口气写了三四十条用例,跑起来绿油油一片。结果呢?开发一改前端,按钮的class从btn-primary换成了btn-solid,你花一个下午把所有脚本里的定位器全改一遍。过了两周,登录页又改版了,输入框多了个wrapper div,你又得重来。这种日子过久了,心里就冒出那句话:UI自动化就是个坑,维护成本比手点还高。

这个问题的核心其实不在自动化本身,而在代码的组织方式。你写的是脚本,不是代码。脚本是录在纸面上的步骤,是像素级复制的“点哪里、输什么”,而代码是有结构、能复用、可维护的工程。PO(Page Object)模式,就是把人从脚本思维拽回工程思维的那根绳子。

PO模式的核心理念只有一句话:把页面抽象成类,把页面上的元素和操作封装成方法。测试用例不再关心某个输入框的xpath是//div[3]/input还是//input[@name='username'],它只调一个login_page.input_username("name")。页面结构变了,改的地方从几十条用例缩减到一个页面类,成本直接降了一个量级。

这套东西不是今天我发明的,也不是什么新鲜概念。Martin Fowler在十几年前就写过PageObject这篇文章,到今天它依然是UI自动化领域最实用、最值得先学的设计模式。Selenium官方文档里也把它列为推荐实践。但网上讲PO的教程很多都停留在“给你看两个类、画个图”的层面,真正的落地细节、代码该怎么分层、遇到页面跳转怎么办、弹窗怎么处理、等待策略放哪,这些东西才是决定你项目是能跑三个月还是跑三天就废的关键。

这篇内容就是围绕PO模式在公司真实项目里的落地过程,把从设计、编码到排坑的完整路径讲清楚。适合刚把自动化跑起来、但被维护成本搞得头大的同学,也适合准备用PO重构老脚本的团队。下面我会用一套完整的登录+商品搜索+加入购物车的场景来说明,每一步都能直接拿去改到自己的项目里。

2. 从“脚本思维”到PO设计:先想清楚再动手

2.1 没有PO之前,自动化代码长什么样

先看一眼典型的反面教材。新手写自动化,最常见的代码长这样:

def test_login(): driver = webdriver.Chrome() driver.get("https://demo.shop.com/login") driver.find_element(By.ID, "username").send_keys("tester01") driver.find_element(By.ID, "password").send_keys("pass123456") driver.find_element(By.CLASS_NAME, "login-btn").click() time.sleep(3) assert driver.find_element(By.CSS_SELECTOR, ".user-info .name").text == "tester01" driver.quit()

这段代码最大的问题不在于它能不能跑通,而在于它把**“你要做什么”“怎么做”**揉成了一团。测试的意图是“验证正确用户名密码可以登录”,但从代码里你只能看到一堆元素定位和操作步骤,意图完全被细节淹没。更致命的是,如果登录按钮的class从login-btn改成submit-btn,你改的不是一行代码,而是所有写了这个定位的用例。

这种代码还有一个隐藏问题:等待全靠time.sleep。sleep(3)意味着最快的情况你等3秒,最慢的情况你只等了3秒,页面加载5秒直接崩。等你把sleep加到5秒,整套用例跑下来又慢得像蜗牛,本来10分钟的回归能跑40分钟。

2.2 PO的核心思想:把页面翻译成类

PO模式的核心设计原则可以归结为三点:

第一,一个页面一个类。登录页对应LoginPage,商品列表页对应ProductListPage,购物车页对应CartPage。页面类的名字跟你业务里的称呼保持一致,让团队里的人一看类名就知道对应哪个页面。

第二,页面的元素和操作都封装在类里。页面上的输入框、按钮、列表项是类的属性,对页面做的一切实操是类的方法。测试用例不碰By.ID、By.XPATH这些定位器,它只调用类的方法。这是解耦的关键:定位信息被隔离在页面类内部,外部根本感知不到。

第三,页面类只描述页面,不做断言。断言是测试用例的职责。页面类负责“做”和“拿”——点击按钮、输入文字、返回某个元素的文本;至于这个结果对不对,由调用它的测试用例来判断。把断言写进页面类,会让页面类承担双重职责,一旦页面结构调整,测试逻辑和页面逻辑一起崩,反而违背了PO解耦的初衷。

2.3 这套设计解决的是什么层面的问题

PO解决的问题,本质上是一个复杂度管理的问题。当用例从十条增长到一百条,如果你每条脚本都独立管理自己的元素定位,那定位信息的副本就有上百份。任何一个页面微调,你就要在上百个位置做修改,这不是工作量的问题,是必然有漏改的问题——你以为全改完了,结果漏了某个用例里的一个xpath,第二天跑回归,红了一片。

PO把定位信息收敛到页面类这一层,副本数量从百份降到个位数。页面改了,你只需要修改对应的Page类,一百条用例不用动,跑起来照样通过。这就是为什么说PO不只是“更好的代码风格”,它是应对UI自动化维护成本的根本手段。

另外一点,PO模式天然推动了团队协作的分工。手工测试或者业务人员可以花半天时间学会写页面类的调用,把精力放在业务逻辑和用例设计上;而元素定位、等待策略、驱动管理这些技术细节,由会写代码的人沉淀到Page类和BasePage基类里。团队里不需要每个人都精通Selenium API,只需要会调方法,自动化用例的编写门槛就降下来了。

3. 搭一套可以直接用的PO框架:项目结构和基类设计

3.1 目录结构怎么摆才合理

先看整体结构。这是我个人比较习惯的、适合中小团队和单个项目的PO框架层级:

auto_test/ ├── config/ │ ├── __init__.py │ └── settings.py # 全局配置:URL、超时时间、浏览器类型 ├── pages/ │ ├── __init__.py │ ├── base_page.py # 所有页面类的基类 │ ├── login_page.py # 登录页 │ ├── product_page.py # 商品列表/详情页 │ └── cart_page.py # 购物车页 ├── test_cases/ │ ├── __init__.py │ ├── conftest.py # pytest夹具:driver初始化与销毁 │ └── test_login.py # 登录相关用例 ├── utils/ │ ├── __init__.py │ └── driver_factory.py # 浏览器驱动工厂 └── reports/ # 测试报告输出目录

这个结构有一个核心原则:从下到上,依赖方向是单向的。config是被一切依赖的基础;pages依赖config里的配置,但不依赖任何test_cases的东西;test_cases依赖pages的方法,自身只关心业务场景的组合。谁都不互相引用,这样改下层不影响上层,改上层不用动下层。

有些团队会把元素定位信息单独抽一个locators目录,page类和定位完全分离。我个人觉得,在项目规模不大(比如20个页面以内)时,定位器直接写在Page类里即可,抽出去反而增加文件数量和跳转成本。但如果你做的是多端、多皮肤、同一套系统要适配不同前端框架的测试,那定位信息抽出来是有价值的——一套Ofh对A版本页面,一套对B版本。

3.2 BasePage基类:把公共能力沉淀下来

BasePage是整个Page层的底座。每个具体的页面类都继承它,所以BasePage设计得好不好,直接决定每个页面类写起来顺不顺手。

我设计的BasePage核心能力包括四块:驱动对象持有、元素查找封装、等待条件封装、公共操作封装。

先看驱动持有。这里有一个关键选择:驱动在哪里创建?常见做法有两种,一种是在每个Page类实例化时传入driver参数,另一种是通过框架的容器(比如pytest fixture)把驱动注入进来。我推荐后者,因为它把驱动的生命周期和用例的执行周期绑定在一起:用例开始前创建,用例结束后销毁,每个用例拿到的是干净的浏览器状态,不会互相干扰。

BasePage代码长这样:

class BasePage: def __init__(self, driver): self.driver = driver # 显式等待统一封装,全局超时从配置读取 self.wait = WebDriverWait(driver, timeout=settings.WAIT_TIMEOUT) def find_element(self, locator): """统一查找单个元素,等待元素可见""" return self.wait.until(EC.visibility_of_element_located(locator)) def find_elements(self, locator): """查找一组元素""" return self.wait.until(EC.presence_of_all_elements_located(locator)) def click(self, locator): """点击操作,等待元素可点击后再点""" element = self.wait.until(EC.element_to_be_clickable(locator)) element.click() def input_text(self, locator, text): """输入文本,先清空再输入""" element = self.find_element(locator) element.clear() element.send_keys(text) def get_text(self, locator): """获取元素文本""" return self.find_element(locator).text def switch_to_window(self, window_index=-1): """切换窗口,常用于点击后新开标签页的场景""" handles = self.driver.window_handles self.driver.switch_to.window(handles[window_index])

这类封装的核心价值在于:所有涉及WebDriverWait、EC条件的复杂Selenium API,都被收敛成了语义明确、参数简单的方法。写页面类的人不需要知道显式等待有几种expected_conditions,只需要知道“我调用find_element,它会等元素出现,超时就报错”。这极大降低了使用门槛。

3.2.1 等待策略选择:为什么坚持用显式等待

BasePage里贯彻的一条铁律是:任何主动等待都用显式等待,绝不使用time.sleep。这不是说sleep完全不能用,而是说sleep是一种无条件的固定等待,它不知道页面状态,只会干等。显式等待是条件等待,它轮询检测某个条件是否成立,成立就立刻继续,超时才抛出异常。

举个例子,页面加载完成后,一个按钮可能在2秒时才会变为可点击,也可能在0.5秒时已经可点击了。用sleep(3)的话,每次请求都在200毫秒的空闲上浪费3秒;用element_to_be_clickable的话,第一次轮询(默认0.5秒间隔)发现可点击就直接继续。一条用例省2-3秒,一百条用例就省4到5分钟,而且还不容易误报超时——因为等待是真实在等元素状态,而不是在等一个拍脑袋估出来的时间。

轮询间隔在Selenium里默认是0.5秒,大多数场景不用调。但如果你的页面操作后有个明显的加载过程,比如点击查询后表格要重新渲染3秒,那建议不要依赖默认,可以在项目里配置一个合适的轮询间隔,或者针对这个操作用专门的WebDriverWait实例,别共享超时时间太短的共同体。

3.3 一个典型案例:LoginPage的实现

有了BasePage垫底,写具体页面类就很轻松了。拿登录页举例:

class LoginPage(BasePage): # 元素定位器:集中在类顶部,便于维护 username_input = (By.ID, "username") password_input = (By.ID, "password") login_button = (By.CLASS_NAME, "login-btn") error_tip = (By.CSS_SELECTOR, ".error-tip") welcome_text = (By.CSS_SELECTOR, ".user-info .name") def login(self, username, password): """登录操作,返回登录后的结果页面(此时通常是首页或用户中心)""" self.input_text(self.username_input, username) self.input_text(self.password_input, password) self.click(self.login_button) def get_error_tip(self): """获取登录失败时的错误提示文本""" return self.get_text(self.error_tip) def get_welcome_name(self): """获取登录成功后的用户名""" return self.get_text(self.welcome_text)

注意几个细节。定位器是类属性,不是实例属性。这样做的原因是,一个页面类只需要一份定位信息,它们不随实例变化,而且在类顶部统一列出所有定位器,一眼就能看完这个页面涉及哪些元素,维护者不需要在全类里翻找某个元素的定位。团队里可以定个规矩:写页面类时,先列定位器,再写方法。

locator的格式统一用元组,这一点很重要。很多初学者写Selenium时直接用find_element(By.ID, "xxx"),PO封装后,这个方法参数就得是一个包含定位方式和一个定位值的元组。使用元组是为了保证BasePage里统一处理,也避免把定位方式以字符串形式散落在各处,导致可读性变差。

4. 真刀真枪:一套“登录-搜索-加购”完整用例落地

4.1 产品流程与业务场景描述

光说不练假把式。下面用一个模拟电商场景来完整展示PO的落地过程。这个系统的业务链路是:

  1. 用户打开商城首页,未登录状态。
  2. 点击“登录”进入登录页,输入账号密码,成功登录。
  3. 在搜索框输入商品关键词,点击搜索,进入商品列表页。
  4. 点击第一个商品进入详情页,点击“加入购物车”。
  5. 购物车中可以看到该商品信息。

在这个场景里,我们用PO模式写出覆盖“登录-搜索-加购”的端到端用例。这个用例覆盖了PO模式最常见也最复杂的两个点:页面跳转时,方法返回什么,以及多个Page对象之间如何协作

4.2 ProductPage和CartPage的设计

登录页的设计上面已经有了。再看商品列表页和购物车页怎么设计。

搜索功能通常在首页头部,也可以在独立的搜索页。为了PO落地更清晰,我把首页和搜索结果页合并成一个ProductPage,因为搜索框和搜索结果列表的重心在商品展示。ProductPage中包含搜索框、搜索按钮、商品列表项、第一个商品的标题等元素。

class ProductPage(BasePage): search_input = (By.CSS_SELECTOR, ".search-input") search_button = (By.CSS_SELECTOR, ".search-btn") product_items = (By.CSS_SELECTOR, ".product-item") first_product_title = (By.CSS_SELECTOR, ".product-item .title") cart_link = (By.CSS_SELECTOR, ".cart-link") def search(self, keyword): """输入关键词并点击搜索,返回新的ProductPage实例""" self.input_text(self.search_input, keyword) self.click(self.search_button) return ProductPage(self.driver) def get_first_product_name(self): """获取第一个商品的名称""" return self.get_text(self.first_product_title) def go_to_detail(self): """点击第一个商品,返回对应的详情页""" self.click(self.first_product_title) # 这里默认跳到了详情页,但我们可以复用ProductPage来获取信息 return ProductPage(self.driver)

购物车页:

class CartPage(BasePage): cart_items = (By.CSS_SELECTOR, ".cart-item") first_item_name = (By.CSS_SELECTOR, ".cart-item .name") total_price = (By.CSS_SELECTOR, ".total-price") def get_first_item_name(self): """获取购物车中第一个商品的名称""" return self.get_text(self.first_item_name) def get_total_price(self): """获取购物车总价""" return self.get_text(self.total_price)

你可能注意到search方法和go_to_detail方法返回的都是ProductPage。这里的设计逻辑是:PO模式下,方法返回值的语义要尽量贴近业务动作的结果——搜索动作的结果是来到商品列表页,点击第一个商品的结果是进入商品详情页。但因为详情页的信息在这个场景里只需要取个商品名,我们就直接复用ProductPage来承载,免得为一次性需求再造一个类。等业务需要详情页独有操作(比如选择规格)时,再拆出来的成本也不高,这是“演进式设计”在PO里的体现。

4.3 测试用例代码:把业务意图写清楚

有了三个Page类,测试用例就可以写得很清爽:

def test_login_search_add_cart(page): # 假设page是BasePage的实例,由conftest注入 login_page = LoginPage(page.driver) # 从登录开始 login_page.go_to_login() # 基类里封装的页面跳转方法 login_page.login("tester01", "pass123456") # 登录后进入首页(商品页) product_page = login_page.enter_home() # 登录成功后跳回首页 product_page.search("无线鼠标") first_product = product_page.get_first_product_name() product_page.go_to_detail() # 加入购物车后跳转到购物车 # 这里简化处理,假设go_to_detail之后在当前页面点加购 product_page.add_to_cart() cart_page = CartPage(page.driver) cart_item = cart_page.get_first_item_name() assert first_product == cart_item

这里我把LoginPage额外加了两个方法:go_to_login和enter_home。它们分别是“从首页进入登录页”和“登录成功后进入首页”的语义封装。实际项目中,从首页到登录页可能涉及点击用户头像、再点击登录按钮,如果把这些选择器散落在用例里,用例读起来就不够业务化了。PO设计一个原则就是:方法名要表达业务动作,而不是操作步骤

这个用例的核心断言是“搜索结果中第一个商品加入购物车后,购物车中第一个商品名称与之一致”。它直接验证了跨页面的数据一致性,是整个端到端链路最核心的业务规则。因为页面类都把获取文本封装成了方法,用例里只需比较两个字符串,可读性很强。

4.4 pytest的conftest里如何管理driver生命周期

上面用例里,page参数的注入依赖于conftest.py的fixture。完整的conftest如下:

# conftest.py import pytest from selenium import webdriver from utils.driver_factory import create_driver from pages.base_page import BasePage @pytest.fixture(scope="function") def page(): driver = create_driver() # 根据配置返回Chrome/Firefox等实例 driver.maximize_window() driver.get(settings.BASE_URL) yield BasePage(driver) driver.quit()

scope用function,确保每个用例都是干净的浏览器环境。你也可以用class或session来加速执行,但要注意会话级别的状态污染——比如登录状态残留、cookie未清空,会导致用例之间的相互影响。我的建议是:初期老老实实用function级别,用例跑稳定了、对哪些可以共享状态有清晰的认知之后,再考虑提升scope。

create_driver的典型实现:

def create_driver(): options = webdriver.ChromeOptions() options.add_argument("--start-maximized") # 可选:headless模式,CI上建议开启 # options.add_argument("--headless=new") driver = webdriver.Chrome(options=options) driver.set_page_load_timeout(30) return driver

需要注意的一点是,create_driver里不要直接driver.get(url),把打开首页这一步放在fixture里。因为不同用例的前置条件不同,有的用例需要先登录,有的用例直接以未登录状态访问商品页,每个用例的初始页面可能不一样。fixture只负责创建驱动、保证浏览器可用,具体访问哪个URL由用例自己决定,灵活度更高。

5. 一等功臣:页面跳转、弹窗、iframe这些难搞场景,PO怎么处理

5.1 页面跳转时,方法应该返回什么对象

这是PO落地中问得最多的问题。问:点击某个按钮后跳转到另一个页面,那么点击方法应该返回什么?答案是:返回那个目标页面的Page对象。这也是PO模式里,页面类之间协作的主要方式。

拿一个常见的场景举例:登录成功后进入用户中心。

class LoginPage(BasePage): # ... 前面的代码省略 def login_success(self, username, password): """登录成功后返回用户中心页面""" self.input_text(self.username_input, username) self.input_text(self.password_input, password) self.click(self.login_button) return UserCenterPage(self.driver) def login_fail(self, username, password): """登录失败,停留在登录页,返回自身""" self.input_text(self.username_input, username) self.input_text(self.password_input, password) self.click(self.login_button) return self

同一个登录操作拆分成login_success和login_fail两个方法。这不是代码冗余,而是把业务分支直接映射到方法上。用例里写起来很清晰:往login_success传正确的账号密码,拿到用户中心页;往login_fail传错误的账号密码,拿到登录页自身。前者用来验证成功路径,后者用来验证失败提示。

这里有一个潜在的坑:login_fail里click之后,如果密码错误且页面出现弹窗,那return self之后类的状态可能已经变了。更稳妥的做法是,在方法里先判断是否出现了错误提示,再决定返回哪个页面。但这会让方法变复杂。我的经验是:初期可以只用login_success和login_fail这种语义化方法,等遇到弹窗、alert阻塞页面操作时,再在方法内部加分支判断。

5.2 弹窗与iframe:封装成页面类的“内部细节”

弹窗(Modal)是Web UI自动化里最讨厌的东西之一。有的弹窗是div模拟的,有的直接是浏览器自带的alert,iframe的出现更麻烦——元素明明在DOM里,但Selenium就是定位不到,因为它在另一个文档里。

PO应对这些复杂结构的核心思路是:把弹窗和iframe的处理彻底封装在Page类内部,不让它污染外部调用者

举个实用的例子,很多网站登录后会在首页右下角弹出“领取优惠券”弹窗,这个弹窗上有关闭按钮。如果不处理它,后续操作可能被遮挡,点击商品链接时被弹窗拦截而导致用例失败。

class HomePage(BasePage): coupon_modal_close = (By.CSS_SELECTOR, ".coupon-modal .close-btn") def close_coupon_modal_if_present(self): """如果有优惠券弹窗就关闭,没有就直接跳过""" try: close_btn = self.driver.find_element(*self.coupon_modal_close) if close_btn.is_displayed(): close_btn.click() # 关闭后等待弹窗消失 self.wait.until(EC.invisibility_of_element_located(self.coupon_modal_close)) except (NoSuchElementException, TimeoutException): pass

这个方法的精妙之处在于“有则关闭,无则跳过”。它用异常捕获代替了显式判断,既不增加代码复杂度,又能适配弹窗有时有、有时没有的情况。业务上,这个逻辑放在进入商品页前的初始化动作里比较合理。

再解决iframe问题。假设某个系统里的富文本编辑器是iframe嵌套的:

class EditorPage(BasePage): editor_iframe = (By.CSS_SELECTOR, ".rich-editor iframe") editor_body = (By.CSS_SELECTOR, "body") def input_content(self, content): """在富文本编辑器中输入内容,自动处理iframe切换""" iframe = self.find_element(self.editor_iframe) self.driver.switch_to.frame(iframe) self.input_text(self.editor_body, content) # 操作完成后必须切回主文档,否则后续定位全部失败 self.driver.switch_to.default_content()

注意最后一行,切回主文档是必须的。很多新手在iframe里操作完元素后忘记切回,导致后面的点击、断言全部报“element not interactable”。这类错误在PO框架里最应该被封装——把switch_to.frame和switch_to.default_content都包进方法里,调用者根本不知道内部有iframe的存在。

5.3 公共导航等跨页面组件,怎么抽象最合适

一个系统的每个页面顶部几乎都有相同的导航栏:首页、分类、购物车、个人中心。如果每个Page类里都重复写一遍这些元素的定位和操作方法,那又是半个维护噩梦。

我常用的方案是:为公共组件也建一个“类”,但它在继承体系上不作为一个独立的Page,而是作为各Page类的mixin或父类。

class TopNavMixin: cart_icon = (By.CSS_SELECTOR, ".nav-cart") user_avatar = (By.CSS_SELECTOR, ".nav-user-avatar") def go_to_cart(self): self.click(self.cart_icon) return CartPage(self.driver) class HomePage(BasePage, TopNavMixin): pass class ProductPage(BasePage, TopNavMixin): pass

这样只要页面顶部有购物车入口,统一的go_to_cart方法就都能用,而且返回的目标页面也是统一的CartPage。当你需要调整购物车入口的定位时,只改一个地方。mixin模式的缺点在于Python的多继承可能会让代码定位逻辑分散,不过在控制好命名和范围的前提下,这套思路非常实用。

6. 常见问题与排坑实录:PO落地中最容易踩的7个坑

6.1 问题速查表

现象根因解决方案
用例间歇性失败,重跑就通过等待策略不统一,隐性依赖网络速度全面替换为显式等待,统一配置超时时间
页面类改动,导致十几个用例同时报错用例直接引用了页面内部定位器严格审计,用例只允许调用Page类方法,不允许出现By.*
点击按钮后页面跳转,但方法返回的还是老页面返回值写死了,没有按业务结果返回目标页对象重新梳理方法语义,确保返回值代表“操作后的状态”
元素定位不到,但手动测试能点元素在iframe里,或者不在当前窗口用switch_to.frame或switch_to.window处理,并封装在Page内
多条用例共用一个driver,登录态互相污染fixture scope设成session/module改回function级别,或单独设计有状态的用例流程
页面类里出现了driver.find_element页面类绕过了BasePage封装代码规范约束:所有元素操作必须走BasePage的封装方法
断言失败但报错信息不知道该看哪断言散落在页面类里把断言统一放到用例层,页面类只做操作和获取值

这张表里的每一条都是我在真实项目中遇到过的。下面挑几条最典型的展开说。

6.2 隐性等待与显式等待混用时,最容易出事

很多团队会在项目初始化时设置driver.implicitly_wait(10),然后又在BasePage用WebDriverWait设置显式等待。这样的组合很危险,因为隐式等待是作用于driver实例全局的,它和显式等待共用一套查找机制时,会导致查找时间叠加——最坏情况下,每次find_element要等隐式等待超时(比如10秒),显式等待的“立即失败”预期就完全失效了。

我的建议是两者选一个。要么用显式等待为主,去掉隐式等待;要么保留隐式等待,但在操作关键元素时额外增加显式等待。推荐前一种,可控制的粒度更细。一个可选的高级方案是:在BasePage里新增一个wait_until_text_appear(locator, text)方法,专门处理“某元素文本变为预期值”的断言场景,内部用显式等待直到文本匹配,比直接断言快得多也稳得多。

6.3 用例间数据共享,千万不要靠全局变量

写PO用例时,很多人会写出这样的代码:

# 用例A product_name = product_page.get_first_product_name() # 这里的product_name存在哪? # 如果存到global变量,用例B要用时再读取,就踩坑了

这种跨用例共享数据的方式极其脆弱。用例执行顺序一调整,或者某一步失败导致数据没被赋值,后面的用例直接崩。我推荐的做法是:把数据创建和校验收在一个用例里。上面那个“搜索-加购”的例子,就是在同一个用例里先获取搜索结果中的商品名,再跟购物车里的商品名做比较。这样既覆盖了业务链路,又不依赖任何外部状态。

如果一定要多步操作(先准备数据、再验证数据),可以考虑用pytest的fixture返回值来传递数据。fixture能保证数据在每个用例前准备好,用例通过函数参数接收数据,不会有全局污染的问题。

6.4 浏览器窗口跳转处理的正确姿势

点击某些链接会新开标签页,此时你手里还拿着旧页面的driver引用,虽然浏览器实际打开了新标签,但driver仍然指向旧页面。传统做法是切window_handles[-1],但处理不当很容易切到错误窗口。

一个更稳的封装:

def switch_to_new_window(self): """切换到新打开的窗口,返回新窗口的Page对象""" current_handle = self.driver.current_window_handle all_handles = self.driver.window_handles for handle in all_handles: if handle != current_handle: self.driver.switch_to.window(handle) break return self

这个方法看起来简单,但它比window_handles[-1]健壮的地方在于:如果新窗口还没打开(点击后有一定延迟),它会等待;如果已有多个窗口,它只切到“非当前”的那个,而不是盲猜最后一个。这里同样可以结合显式等待,直到new_window出现再切,不要用sleep。

6.5 从定位器到行为:让Page类更“像业务操作”

最后分享一个方法论层面的心得。好多人在写Page类时,脑子里还是脚本思维,写出来的方法名跟Selenium API一个样:click_login_button、input_username、click_search_button……这表面上是给Selenium操作起别名,实际上没有利用PO的精髓。

PO的精髓在行为驱动。你要写的是“login”、“search”、“add_to_cart”这种业务操作,而不是“click_button”、“input_text”这种技术操作。方法内部可以组合多个Selenium操作(先点击购物车链接、再等待页面加载、再返回CartPage对象),但方法名和返回值的含义,应该完全用业务语言来表达。

这样做的直接好处:让非技术人员也能看懂用例在干什么。手工测试的同学拿到用例代码,看到的是“登录、搜索、加购”,而不是一堆id和class的堆砌。PO模式降低了自动化用例的维护门槛,也让团队里更多人能参与用例的评审。

7. 关于PO,我还想再说的几句实在话

PO模式最大的敌人不是新框架、不是技术难点,而是过度设计。我见过有团队把PO做得极其复杂:页面类套页面类、切页断言封装、页面事件回调、全自动化定位器管理……结果是代码体积翻了三倍,维护成本和理解成本都上来了,执行效率却没本质变化。PO的价值在于简单清晰的解耦,不是炫技的舞台。

在实际项目中,我个人的经验是:先用PO把结构立起来,然后坚持一条铁律——用例层只允许依赖Page类的方法,不允许出现Selenium的By定位。只要这条铁律不被打破,这套框架就始终可控。后续不管你是接入了Allure报告、加了失败重跑机制,还是引入了云端浏览器执行,PO这层结构都不用动,因为它的核心是稳定的。

再说一句关于自动化测试框架选择的话。网上有各种“自动化测试框架”,很多都内置了PO支持,但核心概念还是这些。如果你已经理解了PO的精髓,用Selenium、Playwright还是Cypress,只是API不同,设计模式是通用的。真正决定自动化项目成败的,不是工具选型,而是你对页面抽象、等待策略、数据管理等工程问题的处理方式。理解了这一点,任何框架你都能很快上手,也会在自动化这条路上走得更远。

如果你现在手里正好有一堆维护艰难的UI自动化脚本,我建议你做的第一件事不是重写所有用例,而是挑一个核心流程,比如登录、或者用户中心,把对应的Page类先写出来,再把涉及这个流程的用例重构掉。跑通一次,你就知道这套模式的好了。剩下的,慢慢来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询