ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Python+Selenium实现PO模型:从登录页到电商业务流封装

Python+Selenium实现PO模型:从登录页到电商业务流封装 1. 从第一个自动化脚本开始为什么你需要PO模型我最早写UI自动化测试时完全没想过什么设计模式就是顺着页面操作一路写下去。等到脚本数量超过十几个问题就来了一个登录按钮的定位方式改了我得翻遍所有用例把find_element(By.ID, login_btn)一个个替换掉。更麻烦的是每条用例里都堆满了等待、点击、断言看起来一片混乱。后来接触到PO模型Page Object Model页面对象模型才意识到自己踩的坑几乎全部可以用这个思路解决。PO模型的核心思想非常简单把页面抽象成对象把页面上的操作封装成方法。测试用例只关心业务逻辑不关心元素怎么定位、等待怎么处理。这样一来页面变了只改Page类用例基本不用动。我当时的感觉是这东西就像是给UI自动化测试做了个分层架构让测试代码从“面向过程”变成了“面向对象”。不管你是刚入门Python自动化测试还是已经写了几百条用例想重构PO模型都值得掌握。本篇文章我会从最基础的例子讲起手把手带你在Python里实现一套可复用的PO模型然后再聊一聊落地时的工程细节和踩坑经验。这篇文章适合的人群很广刚学会Selenium基础、打算做Web自动化测试的Python开发者写过一些用例但感觉代码越来越难维护的测试工程师以及想在某些项目里引入Page Object但不知从何下手的团队。2. 整体设计与思路拆解PO模型到底在解决什么问题在开始写代码之前先花点时间想清楚这件事PO模型不是一套代码模板而是一种组织测试代码的思维方式。2.1 解耦与复用是PO模型的两个核心价值页面对象模型最直观的收益是解耦。以前写用例元素定位和业务操作是混在一起的。而在PO模型里每个页面对应一个类页面上所有可操作的元素和它们对应的行为都封装在这个类里。用例层只需要调用这些方法比如login_page.login(user, pass)完全不需要关心账号输入框的id是什么、密码框在哪里。复用的价值也同样重要。同一套页面操作往往会被多个用例用到比如登录操作、搜索操作、翻页操作。如果每个用例都复制粘贴一套改一个地方就要改N处。用PO模型把公共操作收敛到Page类里修改时只需要动一个文件。另外PO模型对“可读性”的提升也很明显。测试报告里如果出现login_page.login(test01, 123456)谁都能看懂在做什么如果直接摆出一大段driver.find_element...的代码阅读成本就高多了。2.2 方案选型为什么是Python Selenium Pytest聊到具体落地我推荐的环境组合是Python Selenium Pytest这也是目前UI自动化测试里最主流的搭配之一。市面上可供选择的语言很多Java有Selenium WebDriverJavaScript有Playwright和Puppeteer。但我个人不建议新人一上来就折腾过于复杂的语言或框架Python的语法简洁、生态成熟、调试方便配合Selenium几乎可以覆盖所有的Web测试场景。选型时我也会考虑团队的实际情况如果团队有Java背景用Java实现PO模型也完全没有问题如果项目特殊可能还会引入Playwright这类现代化工具。但不管你选什么语言PO模型的思路是通用的核心始终是“页面封装”和“用例分离”。具体到这个实例我会用Python 3.xSelenium 4.xPytest测试框架WebDriver Manager用于浏览器驱动管理这套组合的优点是安装简单pip install selenium pytest文档多遇到问题几乎都能搜到答案非常适合作为PO模型的起步环境。3. 环境准备先把能跑起来的架子搭好理论学习不能替代动手实践。很多初学者卡在环境配置上还没写几行代码就放弃了。这里我把自己常用的配置步骤整理出来跟着做就行。3.1 Python环境的安装建议如果你的电脑还没有Python环境可以从Python官网下载对应系统的安装包或者使用包管理器安装。Linux系统一般自带Python但版本可能较旧建议安装3.8以上的版本。我目前用的是Python 3.11Selenium 4.x对它的支持非常稳定。有个小技巧建议使用虚拟环境。每个项目建一个独立的环境避免不同项目的依赖互相冲突。# 创建虚拟环境 python -m venv venv # Windows激活 venv\Scripts\activate # macOS/Linux激活 source venv/bin/activate # 安装依赖 pip install selenium pytest webdriver-manager虚拟环境的好处一开始可能不明显但等你同时维护几个项目时就会发现这是救命级的好习惯。它不会占用太多空间却能避免很多“在我电脑上是好的”这类尴尬问题。3.2 浏览器与Driver的常见坑Selenium操作浏览器需要浏览器驱动和浏览器版本精确匹配。Selenium 4.x时代推荐使用 webdriver-manager 来自动化处理驱动下载和匹配问题不再需要手动下载driver文件了。from selenium import webdriver from webdriver_manager.chrome import ChromeDriverManager from selenium.webdriver.chrome.service import Service driver webdriver.Chrome(serviceService(ChromeDriverManager().install()))这里有个很容易踩的坑Chrome浏览器自动更新后driver版本不匹配了Selenium启动时报错。用webdriver-manager之后每次运行会检测当前浏览器版本自动下载匹配的driver这个坑基本就绕过去了。如果想看运行效果建议直接用系统默认浏览器先用Chrome后面再考虑Firefox或Edge。不用一上来就搞浏览器网格、并行执行那是后话了。4. 从零写一个PO模型实例登录功能完整实现现在进入正题。我以一个常见的登录页面为例一步步展示如何在Python中实现PO模型。这个例子虽然简单但已经把核心思想全部覆盖了以后无论扩展什么页面都是同样的套路。4.1 目录结构和核心类的设计标准的PO模型项目通常会按照下面的目录结构来组织project/ ├── pages/ │ ├── __init__.py │ ├── base_page.py │ └── login_page.py ├── testcases/ │ ├── __init__.py │ └── test_login.py ├── conftest.py └── pytest.ini我来解释一下每部分的作用pages/存放所有页面对象类。base_page.py基类封装所有页面共用的操作方法。login_page.py登录页面对象继承基类定义登录页的元素和操作。testcases/存放测试用例。conftest.pyPytest的fixture定义负责driver的创建和销毁。pytest.ini配置文件。4.2 基类BasePage把公共操作沉淀下来在没写具体的登录页之前先写一个基类。基类的作用是沉淀所有页面都可能会用到的操作方法比如点击元素输入文本获取元素文本等待元素可见等待元素可点击# pages/base_page.py from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.remote.webelement import WebElement class BasePage: def __init__(self, driver): self.driver driver self.timeout 10 def find_element(self, locator): 根据定位符查找元素确保元素可见 wait WebDriverWait(self.driver, self.timeout) return wait.until(EC.visibility_of_element_located(locator)) def find_clickable_element(self, locator): 查找可点击的元素 wait WebDriverWait(self.driver, self.timeout) return wait.until(EC.element_to_be_clickable(locator)) def click(self, locator): 点击元素 element self.find_clickable_element(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这个方法封装有什么好处我可以直接去测试用例里写self.click((By.ID, submit))但这还不是终极形态。真正的PO模型是让页面类只暴露业务方法不暴露底层查找细节。4.3 登录页Page Object封装元素与行为登录页一般包含三个部分用户名输入框、密码输入框、登录按钮。相应的Page对象中应该提供三个方法输入用户名、输入密码、点击登录。# pages/login_page.py from selenium.webdriver.common.by import By from pages.base_page import BasePage class LoginPage(BasePage): 登录页面对象 # 元素定位信息统一定义 username_input (By.ID, username) password_input (By.ID, password) login_button (By.ID, loginBtn) error_msg (By.CLASS_NAME, error-tip) def input_username(self, username: str): 输入用户名 self.input_text(self.username_input, username) def input_password(self, password: str): 输入密码 self.input_text(self.password_input, password) def click_login(self): 点击登录按钮 self.click(self.login_button) def login(self, username: str, password: str): 完整的登录操作 self.input_username(username) self.input_password(password) self.click_login() def get_error_message(self) - str: 获取登录失败的提示信息 return self.get_text(self.error_msg)看到这里你应该已经感受到了用例里如果调用login_page.login(admin, 123456)那它就是一句话能讲清楚的业务操作。将来如果前端把用户名输入框的id改了所有调用这个方法的用例都不用动只需要改username_input这行定位即可。这种封装方式就是把页面上的“元素”和“操作”绑定到了一个对象里业务操作是页面对象对外暴露的接口元素定位则是内部的私有实现。4.4 写用例在Pytest中调用Page Object有了页面对象写测试用例就变成了一件很清爽的事。我们只需要关注业务逻辑# testcases/test_login.py import pytest from pages.login_page import LoginPage class TestLogin: def test_login_success(self, init_driver): driver init_driver driver.get(https://example.com/login) login_page LoginPage(driver) login_page.login(admin, 123456) # 登录成功后判断当前URL是否跳转到首页 assert dashboard in driver.current_url def test_login_wrong_password(self, init_driver): driver init_driver driver.get(https://example.com/login) login_page LoginPage(driver) login_page.login(admin, wrongpass) assert login_page.get_error_message() 用户名或密码错误用例中基本看不到By.ID这类底层细节有的只是业务意图登录、断言错误提示。这样写出来的用例本身就是一份可读性很好的测试文档。4.5 conftest.py与init_driver夹具上面用例里用到的init_driver是一个Pytest fixture定义在conftest.py中。它的作用是统一管理浏览器实例的生命周期用例开始前打开浏览器用例结束后关闭浏览器。# conftest.py import pytest from selenium import webdriver from webdriver_manager.chrome import ChromeDriverManager from selenium.webdriver.chrome.service import Service pytest.fixture def init_driver(): driver webdriver.Chrome(serviceService(ChromeDriverManager().install())) driver.maximize_window() driver.implicitly_wait(5) yield driver driver.quit()关于 fixture 的作用域如果你有多条用例需要共享同一个浏览器实例可以把 fixture 的 scope 改为module或sessionpytest.fixture(scopemodule) def init_driver(): ...但要注意模块级共享浏览器虽然执行速度快用例之间的依赖会变强一条用例跑挂了可能影响后面的用例。建议初期先用默认的函数级作用域跑通了再优化性能和共享策略。4.6 运行测试与生成报告在项目根目录下运行pytest -v如果希望生成HTML测试报告可以装一个pytest-html插件pip install pytest-html pytest -v --htmlreport.html测试报告会记录每条用例的通过、失败状态以及失败时的简要信息。配合PO模型后失败用例基本能在几秒内定位到是哪个页面的哪一步操作出了问题。5. 实操细节把PO模型从“能用”变成“好用”代码能跑起来只是第一步。在实际项目中PO模型落地时常会遇到各种细节问题这里我把自己实践中的经验整理一下。5.1 元素定位方式不要只依赖一种策略写Page Object时元素定位符的选择非常关键。新手容易犯的错误是全部用By.XPATH或者全部用By.ID。我的建议是有稳定ID的优先用By.ID定位快、简洁。没有ID的可以用By.NAME或By.CSS_SELECTOR。动态生成的元素、结构复杂难定位的再用By.XPATH尽量写到最精确的匹配。举个例子如果一个按钮的ID是submit-btn写(By.ID, submit-btn)就比一长串的XPath好维护得多。另一个很容易忽略的问题是定位符尽量保持稳定。如果一个元素的位置会随着页面渲染动态变化就要考虑是否该用相对路径。5.2 显式等待优先于隐式等待Selenium里有两个等待机制隐式等待implicitly wait和显式等待explicit wait。隐式等待作用于全局在轮询查找元素时生效但它对元素不可见、不可点击这些状态无能为力。显式等待针对某个条件进行等待比如元素可见、可点击、文本出现等。在实际项目中我强烈建议优先使用显式等待。在BasePage中我习惯把查找元素、点击、输入都封装成带显式等待的方法。这样就能保证页面还在渲染时不会因为立刻找不到元素而报错。等的时间不要设太长3秒到10秒足够应对绝大多数场景时间太长会让失败用例的等待时间也变得漫长。5.3 合理处理页面跳转和弹窗有些操作会引起页面跳转弹出新窗口、iframe切换等。在PO模型中这些细节也应该被封装起来。比如遇到一个iframe弹窗元素必须切进iframe里才能操作from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC class ModalPage(BasePage): modal_frame (By.CLASS_NAME, modal-iframe) confirm_button (By.ID, confirmBtn) def confirm(self): wait WebDriverWait(self.driver, self.timeout) frame wait.until(EC.frame_to_be_available_and_switch_to_it(self.modal_frame)) self.click(self.confirm_button) # 操作完毕切回默认上下文 self.driver.switch_to.default_content()这类处理逻辑如果散落在用例里会显得很混乱封装到Page对象里之后用例只需要调用modal_page.confirm()就可以了。5.4 保持Page对象的职责单一一个常见的错误是把同一个页面的所有功能都塞进一个巨大的Page类里。例如登录页和注册页可能在同一个页面上但它们的元素和操作完全不同。我的建议是按照功能模块拆分Page对象登录页单独一个LoginPage注册页单独一个RegisterPage如果页面里有多套表单分别建类而不是堆在一个类里这样不仅类更清晰后期维护时也能更快找到对应代码。6. 常见问题与排查技巧PO模型落地时的九大坑无论框架写得多漂亮实际跑起来总是会遇到各种意外。这里我把最常见的几种问题整理成速查表希望能帮你少走弯路。6.1 问题速查表现象可能原因排查方向元素找不到报NoSuchElementException元素定位符写错或页面未加载完成检查定位符增加显式等待点击无效但不报错元素被遮挡或不是预期元素确认是否有弹窗或iframe检查元素是否可点击所有用例都因为浏览器启动失败驱动与浏览器版本不匹配关闭自动更新使用webdriver-manager用例之间数据相互影响fixture作用域过大或测试数据没有隔离检查fixture的scope确保用例独立登录后跳转不稳定页面跳转需要时间在断言前使用等待条件等待URL或元素出现服务器返回500/502测试环境本身不稳定排除脚本问题检查服务日志无头模式找不到元素无头模式下页面渲染方式不同先有头模式调试再切无头执行xpath复制过来却定位不到路径中的属性值是动态的尽量使用稳定的属性和相对路径测试长时间不结束等待时间设置过长检查显式等待的timeout设置6.2 动态元素的处理心得现代前端框架React、Vue渲染速度很快但有些元素的属性值会动态变化比如id里带上时间戳。这时候最忌讳拿一整条带动态值的XPath去定位。解决办法是找元素的“稳定锚点”类名class、文本内容、相邻元素的属性等。比如一个按钮ID是动态的但它旁边有一个固定文字“提交”就可以用//button[contains(text(), 提交)]来定位或者用CSS选择器配合class属性。6.3 失败数据的快速定位有些用例挂掉了但错误信息不够直观。我建议在BasePage里增加一个截图方法# pages/base_page.py import os import time def take_screenshot(self, nameNone): if name is None: name time.strftime(%Y%m%d_%H%M%S) file_name fscreenshots/{name}_{self.driver.session_id}.png os.makedirs(screenshots, exist_okTrue) self.driver.save_screenshot(file_name) return file_name然后在用例失败时调用def test_login_wrong_password(self, init_driver): ... try: assert login_page.get_error_message() 用户名或密码错误 except AssertionError: login_page.take_screenshot(login_failed) raise截图能直观看出当时的页面状态排查问题时比看一长串HTML日志高效得多。7. 进阶拓展PO模型与数据驱动、自定义断言、CI集成基础模型搭建好之后接下来可以从几个方向继续提升自动化测试能力和工程质量。7.1 数据驱动从一条用例变成多组数据登录测试最好用数据驱动的方式把不同账号和期望结果放到一起减少重复代码。# testcases/test_login_data.py import pytest from pages.login_page import LoginPage test_data [ (admin, 123456, True), (admin, wrongpass, False), (, 123456, False), (admin, , False), ] class TestLoginData: pytest.mark.parametrize(username,password,expect_success, test_data) def test_login_cases(self, init_driver, username, password, expect_success): driver init_driver driver.get(https://example.com/login) login_page LoginPage(driver) login_page.login(username, password) if expect_success: assert dashboard in driver.current_url else: assert login_page.get_error_message() ! 这样表和PO模型结合用例的覆盖面可以迅速扩大而代码量几乎不增加。7.2 Allure报告与失败回放Allure是我比较推荐的测试报告工具。安装后只需在执行时加一行参数pip install allure-pytest pytest -v --alluredir./allure-results allure serve ./allure-resultsAllure报告里能直观看到每一步操作、截图、附带的日志信息。配合PO模型的分层架构一条用例失败后报告里可以清楚看到是“LoginPage.login”方法内部的哪一步挂了定位问题快得多。7.3 接入CI/CD让测试定时跑、自动跑PO模型整理好后完全可以接入CI流程。在Git仓库里配置好依赖安装与执行命令每次代码变更后自动跑一遍冒烟测试也可以设定定时任务每天晚上跑全量回归。CI里通常需要使用无头浏览器执行测试options webdriver.ChromeOptions() options.add_argument(--headless) options.add_argument(--no-sandbox) options.add_argument(--disable-dev-shm-usage) driver webdriver.Chrome(serviceService(...), optionsoptions)这里有两个易踩的坑Linux服务器上跑Chrome需要安装一堆系统依赖库直接运行会报错。用Docker镜像解决比较干净。无头模式下有些动态交互可能表现不同建议先在本地有头模式调试通过后再切到CI执行。7.4 更多扩展日志、自动重试、多浏览器支持工程化还可以继续演进日志系统在BasePage中集成logging每一步操作都记录执行过程和参数方便分析。自动重试对偶发性的网络波动或页面加载失败可以用Pytest的插件做失败重试但不建议所有用例都无脑重试会掩盖真实bug。多浏览器支持通过配置文件或命令行参数在Chrome、Firefox、Edge之间自由切换。# conftest.py import pytest import os from selenium import webdriver pytest.fixture def init_driver(): browser os.getenv(BROWSER, chrome).lower() if browser firefox: driver webdriver.Firefox() elif browser edge: driver webdriver.Edge() else: driver webdriver.Chrome() ...8. 一个更完整的实例多页面联动与业务流封装登录只是开始实际项目中更常见的是跨页面完成一个业务流比如“登录→搜索→加入购物车→下单”。这种情况下PO模型的优势更加明显。以电商平台的“搜索加购”流程为例# pages/home_page.py from selenium.webdriver.common.by import By from pages.base_page import BasePage class HomePage(BasePage): search_input (By.NAME, q) search_button (By.CSS_SELECTOR, button.search-btn) def search(self, keyword: str): self.input_text(self.search_input, keyword) self.click(self.search_button) return SearchResultPage(self.driver)注意到没有search方法返回了一个新的页面对象SearchResultPage。这就是页面跳转的封装方式页面操作会产生新的页面方法返回对应页面对象用例可以连起来写。# pages/search_result_page.py from selenium.webdriver.common.by import By from pages.base_page import BasePage class SearchResultPage(BasePage): first_product (By.CSS_SELECTOR, .product-item a) def click_first_product(self): self.click(self.first_product) return ProductDetailPage(self.driver)再写产品详情页# pages/product_detail_page.py from selenium.webdriver.common.by import By from pages.base_page import BasePage class ProductDetailPage(BasePage): add_cart_button (By.ID, addToCart) cart_count (By.CLASS_NAME, cart-count) def add_to_cart(self): self.click(self.add_cart_button) def get_cart_count(self) - str: return self.get_text(self.cart_count)用例层就非常像一份操作说明书# testcases/test_buy_flow.py import pytest from pages.login_page import LoginPage from pages.home_page import HomePage class TestBuyFlow: def test_search_and_add_cart(self, init_driver): driver init_driver driver.get(https://example.com) home_page HomePage(driver) result_page home_page.search(机械键盘) detail_page result_page.click_first_product() detail_page.add_to_cart() assert detail_page.get_cart_count() 1这就是PO模型最有魅力的地方业务流程是一层一层串联起来的页面对象负责每个环节的动作用例负责编排业务和做断言。后期如果产品详情页的“加入购物车”按钮变了只需要改ProductDetailPage里的定位符用例一行不动。9. 我在实际项目中使用PO模型的一些体会写自动化测试这些年我最大的体会是PO模型的价值不是让用例跑得更快而是让团队在迭代中维护测试的成本更低。很多人一开始觉得写Page类很麻烦、多了一层封装但等到产品需求频繁变化、前端组件频繁调整时就会发现这一层封装省了太多时间。有几个细节可以分享第一Page类里尽量不要写业务断言。断言属于用例层Page类负责操作和状态获取。如果把断言下沉到Page类会导致同一个页面在不同业务场景下的断言逻辑互相纠缠。第二不要为了PO而PO。如果一个页面只有一两个操作、且几乎不会变动直接写在用例里问题也不大。PO模型的核心价值在于复用和维护要结合项目实际情况判断是否需要抽象。过度设计同样会让代码变得臃肿。第三页面的初始化参数建议只传driver不要传入一堆配置。如果需要区分环境可以在启动driver时就确定好环境地址Page类内部只关心当前页面上的元素和操作。最后刚开始接触PO模型的读者我建议先从一个简单的登录页开始手动写一遍完整的代码再去看别人封装好的框架。只有自己动手走了完整流程才能真正理解为什么基类、页面类、用例、fixture要这样拆分也才能在后续的项目里举一反三。
返回列表