ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Python Selenium自动化测试框架中的POM模式设计与源码实现

2026/9/15 21:09:29 拓冰建站 浏览量
Python Selenium自动化测试框架中的POM模式设计与源码实现 简介基于POM模式的Python Selenium自动化测试框架设计源码面向测试开发工程师与自动化测试初学者聚焦理财系统中P2P借款、投资等核心页面的Web自动化测试。框架采用页面对象模型将页面元素与操作封装成对象显著提升用例可维护性和复用性并通过登录、个人借款、投资模块的完整测试用例展示数据驱动与异常场景设计思路适合直接运行、二次开发或作为课程设计参考。压缩包为zip格式共165个文件整体大小约10.81MB以7个Python源码文件为核心辅以124个JSON测试配置文件、5个XML配置、24张PNG截图以及pytest.ini、日志和说明文档等工程文件目录结构清晰便于按模块查阅和快速定位所需代码。已有517人浏览学习读者可从中掌握Selenium WebDriver操作、pytest框架整合、测试数据分离、页面对象封装等关键技能同时结合金融理财场景理解POM模式的工程落地方法快速搭建属于自己的自动化测试项目。1. 为什么说POM模式是Python Selenium自动化测试框架的地基而不只是一个目录规范把测试代码里的driver.find_element(By.ID, login).click()换成page.login(admin, 123456)背后不只是少写几行代码。多数Selenium项目的维护成本高根因是元素定位和业务操作混在同一个函数里页面改版时一个定位表达式散落十几处改一处漏一处。POMPage Object Model要拆掉的就是这份耦合每个页面或页面组件抽象成一个类定位器只作为类字段存在用户能在这个页面上做的操作收敛成类方法测试脚本只向页面对象发指令。本文要展开的是一套基于POM的Python Selenium自动化测试框架源码设计从目录结构、BasePage封装到页面对象类与pytest用例怎么落适合做Web UI自动化的测试开发直接拿去改也适合新手跟着源码结构搭出第一个能跑的框架。2. POM模式核心设计页面建模、定位器元数据与Python项目的五层目录先划清一个容易踩的歧义这里的POM是Page Object Model不是Maven里那个pom.xml。Java项目里pom.xml管依赖Python自动化框架里的POM管页面抽象两者唯一共同点是“把重复的事收敛到一处”。搞清楚这个边界后面看代码才不会串。2.1 页面对象化的两个转换页面元素收敛成类字段用户操作收敛成类方法建模时先识别这个页面有哪些“用户可感知的操作”。拿登录页举例输入用户名、输入密码、点击登录、看到错误提示、登录后跳转。这些动作在POM里全部转成LoginPage的方法或属性。字段只保留两样东西定位器元数据和操作结果页面上跟业务动作无关的元素不要全暴露出来比如页脚Logo除非断言脚本关心它否则不必建模。转化后测试脚本的视角就变了不再面向driver编程而是面向页面对象编程。login(username, password)这个签名把“怎么定位输入框、怎么清空、怎么敲键盘”全部吞掉。带来的直接收益是定位器变更只会波及页面类内部用例本身不动另一个收益在协作上用例的可读性降到了产品经理都能review的程度。这里有一个隐性约定元素对象本身不能作为对象属性缓存页面一刷新旧元素引用就失效所以类里只存定位器元数据不存WebElement实例。2.2 定位器元数据的三种写法裸元组、枚举与PageFactory的取舍最常见也最推荐的写法是每一条定位器用一个二元组表示格式固定为(By.XXX, selector)。它是框架源码里的标准单位拿到后直接find_element(*locator)解包使用。字段名用业务含义值用定位语法一行一个集中写在类顶部方便统一排查。# pages/login_page.py from selenium.webdriver.common.by import By class LoginPage: username_input (By.CSS_SELECTOR, input[nameusername]) password_input (By.CSS_SELECTOR, input[namepassword]) login_button (By.CSS_SELECTOR, button[typesubmit])定位器二元组的两个元素说明第一项是selenium.webdriver.common.by.By提供的定位策略枚举第二项是对应的选择器表达式。CSS选择器优先于XPath因为后者在复杂页面里容易出现层级耦合页面微调就断只有需要按文本内容匹配时才用XPath的contains(text(), ...)。部分项目会把定位器升级成元素枚举用Enum约束定位器集合IDE补全更明确但使用时多一层.value取值# base/locator.py from enum import Enum from selenium.webdriver.common.by import By class LoginLocators(Enum): USERNAME_INPUT (By.CSS_SELECTOR, input[nameusername]) PASSWORD_INPUT (By.CSS_SELECTOR, input[namepassword]) # 解包使用 # element driver.find_element(*LoginLocators.USERNAME_INPUT.value)还有第三种思路是PageFactoryJava系框架经常这么干Python里借助page-object库把定位器放在装饰器上代码表面更简洁。我一般不用它做源码设计原因很实际装饰器把定位器拆散到方法定义旁边回溯元素定义时要上下文跳来跳去而且它引入的隐式转换让“定位失败”的堆栈变长排查成本反而升高。团队有Java背景可以尝试Python原生项目里裸元组的表达力已经够了。2.3 五层目录结构base、pages、tests、config、utils各自守什么边界框架源码的地基是目录分层我通常按下面这个结构拆它把“通用能力”和“业务页面”从物理上隔开selenium_pom_framework/ ├── config/settings.py # 环境参数base_url、超时、浏览器类型 ├── base/base_page.py # BasePage 基类定位、点击、输入、等待、截图 ├── base/locator.py # 定位器枚举定义按页面拆分模块 ├── pages/login_page.py # 登录页对象类 ├── pages/home_page.py # 主页对象类 ├── tests/conftest.py # pytest fixture浏览器生命周期 ├── tests/test_login.py # 登录相关用例 ├── utils/logging_setup.py # 日志初始化 ├── cases/login_cases.json # 数据驱动测试数据 ├── reports/ # HTML报告与截图输出 └── requirements.txt # selenium pytest pytest-html分层背后的依赖方向是硬约束tests依赖pages和basepages依赖baseconfig只提供参数utils是横切组件。反向依赖一律禁止——pages里绝不出现tests的引用base不感知任何具体页面。这个约束保证框架的每层都能独立替换比如换一套页面对象实现基类和用例都不必动。config/settings.py里我只放环境相关参数常量和业务账号数据不混进来# config/settings.py import os BASE_URL os.getenv(BASE_URL, http://127.0.0.1:8080) DEFAULT_TIMEOUT int(os.getenv(DEFAULT_TIMEOUT, 10)) BROWSER os.getenv(BROWSER, chrome) HEADLESS os.getenv(HEADLESS, 1) 1这里os.getenv的作用是允许CI流水线用环境变量覆盖默认值本地跑直接用默认配置。超时参数收敛在这一处BasePage和测试用例都不许写死timeout10统一读取DEFAULT_TIMEOUT改一处全局生效。3. BasePage基类源码把定位、点击、等待、截图做成统一出口3.1 为什么所有页面对象必须继承同一个BasePage页面对象类之间不直接引用所有公共能力下沉到BasePage。这样做的直接原因是日志、等待策略和异常处理只能有一个出口。如果每个页面类自己写find_element有的带等待有的不带失败排查时就不知道元素是没渲染还是定位器写错。基类只接收WebDriver实例和基础URL两个构造参数不关心具体页面是什么这保证它在整个框架里是“无业务”的。BasePage还有一个隐性职责把Selenium原生API里反直觉的部分挡在门外。比如element.clear()对某些自定义组件会抛异常.text在元素不可见时返回空串这些坑如果散落在各页面类里每个类都要处理一遍。收敛到基类后坑只需要踩一次。3.2 公共方法源码find_element、click、input_text与截图落盘# base/base_page.py import logging from datetime import datetime from pathlib import Path from selenium.webdriver.common.by import By from selenium.webdriver.remote.webdriver import WebDriver from selenium.webdriver.remote.webelement import WebElement from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import WebDriverWait class BasePage: def __init__(self, driver: WebDriver, base_url: str ): self.driver driver self.base_url base_url self.logger logging.getLogger(type(self).__name__) def find_element(self, locator: tuple, timeout: int 10) - WebElement: if not isinstance(locator, tuple) or len(locator) ! 2: raise ValueError(locator 必须是 (By.XXX, selector) 形式的二元组) wait WebDriverWait(self.driver, timeout) return wait.until(EC.presence_of_element_located(locator)) def find_elements(self, locator: tuple, timeout: int 10) - list[WebElement]: wait WebDriverWait(self.driver, timeout) return wait.until(EC.presence_of_all_elements_located(locator)) def click(self, locator: tuple, timeout: int 10) - None: wait WebDriverWait(self.driver, timeout) element wait.until(EC.element_to_be_clickable(locator)) self.logger.info(click %s, locator) element.click() def input_text(self, locator: tuple, text: str, timeout: int 10) - None: element self.find_element(locator, timeout) element.clear() element.send_keys(text) self.logger.info(input %d chars into %s, len(text), locator) def get_text(self, locator: tuple, timeout: int 10) - str: return self.find_element(locator, timeout).text def is_visible(self, locator: tuple, timeout: int 10) - bool: try: wait WebDriverWait(self.driver, timeout) wait.until(EC.visibility_of_element_located(locator)) return True except Exception: return False def take_screenshot(self, name: str ) - Path: folder Path(screenshots) folder.mkdir(exist_okTrue) filename f{datetime.now():%Y%m%d_%H%M%S}_{name or page}.png path folder / filename self.driver.save_screenshot(str(path)) self.logger.info(screenshot saved to %s, path) return path先说find_element里的类型检查isinstance判断看起来多余但实际能拦住“把字符串当locator传进来”的低级错误。WebDriverWait先等元素出现在DOM里再返回元素对象比直接driver.find_element多一层“给页面渲染留时间”的缓冲。click单独用了EC.element_to_be_clickable而不是find_element里的presence_of_element_located因为“渲染出来了”和“能点击”是两件事按钮可能被遮罩层盖住可能还在加载动画里。用可点击条件等待等的是元素可见且可交互。take_screenshot里mkdir(exist_okTrue)保证目录不存在时自动创建。截图命名带时间戳和业务名失败现场可以直接挂到测试报告上。参数表的对应关系如下方法参数返回值说明find_elementlocator: 定位二元组, timeout: 秒WebElement等待元素出现在DOM中find_elements同上list[WebElement]列表场景如下拉选项、表格行clicklocator, timeoutNone等待元素可点击后点击input_textlocator, text, timeoutNone清空后输入适用于input和textareaget_textlocator, timeoutstr取元素文本常见于断言is_visiblelocator, timeoutbool元素可见返回True超时返回Falsetake_screenshotname: 业务名Path截图存入screenshots目录3.3 等待策略的3个关键参数timeout、poll_frequency与两种等待混用的坑WebDriverWait的构造参数里timeout是总超时秒数poll_frequency是轮询间隔默认0.5秒这两个值组装成了重试机制。wait.until会按poll_frequency周期反复检查条件直到条件成立或超时。实际项目里timeout按元素类型分级输入框给3到5秒按钮给5到8秒列表刷新和页面跳转给10秒以上。这个分级一般放在config/settings.py里按需微调。提示setup_method里如果已经调用了driver.implicitly_wait(3)同时BasePage里又用WebDriverWait(driver, 10)单次查找的最坏等待时间接近两者相加而不是取较大值。显式等待和隐式等待机制独立两个计时器会叠加。框架源码里应统一只用显式等待避免这类隐式叠加。time.sleep在等待场景里是反模式——固定睡眠让用例变慢网络慢时又容易误报失败。需要自定义条件时扩展点放在基类里# base/base_page.py def wait_until(self, condition, timeout: int 10, message: str ): wait WebDriverWait(self.driver, timeout) return wait.until(condition, message)参数说明condition是expected_conditions模块里的条件对象也可以传自定义lambda例如等待表格行数从10变成11message在超时后被拼进异常信息排查用例时能直接看到“等的是什么”。这个方法是BasePage给上层留的扩展口业务页面类有特殊等待需求时不必绕过基类。4. 页面对象类与pytest用例源码把登录场景跑通的最小框架4.1 登录页对象类源码open、login、error_message三个方法的边界业务页面类在POM里要克制方法只暴露用户操作和断言用到的状态不暴露元素细节。以登录页为例源码可以这样组织# pages/login_page.py from selenium.webdriver.common.by import By from base.base_page import BasePage class LoginPage(BasePage): username_input (By.CSS_SELECTOR, input[nameusername]) password_input (By.CSS_SELECTOR, input[namepassword]) login_button (By.CSS_SELECTOR, button[typesubmit]) error_alert (By.CSS_SELECTOR, div.alert-danger) welcome_title (By.CSS_SELECTOR, h1.welcome) def open(self) - LoginPage: self.driver.get(f{self.base_url}/login) return self def login(self, username: str, password: str) - None: self.input_text(self.username_input, username) self.input_text(self.password_input, password) self.click(self.login_button) def error_message(self) - str: if self.is_visible(self.error_alert, timeout3): return self.get_text(self.error_alert) return def login_success(self) - bool: return self.is_visible(self.welcome_title, timeout5)open返回self是为了链式调用用例里一条语句完成“进页面登录”的组合操作。login方法的执行顺序必须严格按用户操作习惯排列先用户名后密码最后点登录这个顺序本身就是用例的隐性文档。error_message和login_success是专门为断言设计的“查询方法”。它们把“登录成功”翻译成“欢迎标题可见”把“登录失败”翻译成“错误提示的文本”。测试脚本不用知道页面上具体是什么元素业务含义直接体现在方法名上。is_visible内部用try包住等待超时返回False而不是抛异常这是给断言用的关键设计——断言需要的是布尔结果不是异常堆栈。4.2 在tests里用pytest写登录用例fixture接管浏览器生命周期测试层用pytest组织浏览器生命周期交给fixture管理。conftest.py里定义fixture测试函数通过参数名自动注入# tests/conftest.py import pytest from selenium import webdriver from config import settings pytest.fixture def browser(): options webdriver.ChromeOptions() if settings.HEADLESS: options.add_argument(--headlessnew) driver webdriver.Chrome(optionsoptions) yield driver driver.quit() pytest.fixture def base_url() - str: return settings.BASE_URLfixture的关键在yield两侧yield之前的代码是setup之后的代码在用例结束后执行相当于teardown。driver.quit()写在yield之后保证每个用例的浏览器都真正关闭不会残留进程占着端口。HEADLESS从settings里读本地调试时设成0可以看到浏览器动作CI里设1省资源。用例文件只关心页面对象和业务断言# tests/test_login.py from pages.login_page import LoginPage def test_login_success(browser, base_url): page LoginPage(browser, base_url).open() page.login(admin, 123456) assert page.login_success() def test_login_wrong_password(browser, base_url): page LoginPage(browser, base_url).open() page.login(admin, wrong-password) assert 用户名或密码错误 in page.error_message()用例里没有出现任何find_element和By这就是POM框架分层后的效果。两个用例的差异只在输入数据和断言方向正向用例断言成功态反向用例断言错误文案。page变量每一行都在操作页面对象测试的意图从上读到下就是一条用户操作路径。4.3 三类高频元素处理上传文件、iframe与非原生下拉框实际项目中登录页只是起点落地到业务页面时有三个高频场景要提前在BasePage或页面类里给方案。文件上传不需要AutoIT或pywin32原生input[typefile]直接用send_keys传绝对路径就能触发file_input (By.CSS_SELECTOR, input[typefile]) self.driver.find_element(*file_input).send_keys(/absolute/path/upload.csv)send_keys对文件输入框的含义是“把文件路径填进控件并触发选择”所以传的是字符串路径而不是模拟点击弹窗。这个API绕过了系统对话框是Selenium处理上传的常规姿势。注意路径要用绝对路径相对路径在部分浏览器里不会被解析。iframe框架内元素处理必须先切上下文再定位from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import WebDriverWait frame_locator (By.CSS_SELECTOR, iframe#content) wait WebDriverWait(self.driver, 10) wait.until(EC.frame_to_be_available_and_switch_to_it(frame_locator)) # 此时才能定位iframe内部的元素 self.driver.switch_to.default_content()参数说明frame_to_be_available_and_switch_to_it在等待iframe可用的同时完成上下文切换比先切再等更稳。操作完iframe内元素后必须switch_to.default_content()切回主文档否则后续定位全在错误的上下文中执行报错还特别难排查。非原生下拉框也就是热搜词里常出现的divulli组合不能用Select类def select_li_option(self, trigger: tuple, option_text: str): self.click(trigger) # 先点击div展开下拉列表 option_locator (By.XPATH, f//li[contains(text(), {option_text})]) self.click(option_locator)这种组件的交互逻辑是“点击触发器等待列表展开再点击目标项”trigger是那个模拟下拉框的div的定位器。option_text如果是动态数据用># tests/test_login_data.py import json import pytest from pages.login_page import LoginPage def load_cases(): with open(cases/login_cases.json, encodingutf-8) as f: return json.load(f) pytest.mark.parametrize(case, load_cases()) def test_login_from_json(browser, base_url, case): page LoginPage(browser, base_url).open() page.login(case[username], case[password]) if case[expect_success]: assert page.login_success() else: assert case[expect_error] in page.error_message()[ {username: admin, password: 123456, expect_success: true}, {username: admin, password: wrong, expect_success: false, expect_error: 用户名或密码错误}, {username: , password: 123456, expect_success: false, expect_error: 用户名不能为空} ]load_cases()在模块加载时执行一次把JSON里的每条记录展开成一个独立的测试用例。每条用例的执行结果互不影响某一条失败不会阻断其他数据的验证。case字典里的键名就是数据契约新增一条用例只需要往JSON里加一行不需要动代码。5.2 报告与日志该盯哪几个字段报告我用pytest-html生成执行命令加上--self-contained-html参数把CSS和JS全部嵌入单个HTML文件方便发到群里或通过邮件转发pytest tests/ --htmlreports/report.html --self-contained-html -s报告里重点看两类信息失败用例的Failure字段下方有没有把轮询等待的超时异常和logger.info记录的定位器打印出来Screenshots字段有没有指向take_screenshot生成的PNG文件。BasePage里每条操作都打了INFO日志失败时日志能还原出“走到哪一步挂的”——是点击登录按钮超时还是登录后等不到欢迎标题。日志级别设为INFO时用例执行轨迹完整可见排查定位问题时临时调到DEBUG能看到Selenium内部每次轮询的状态。生产跑回归用WARNING级别只保留等待超时和异常信息避免日志文件膨胀。5.3 上线前的四步基线自检框架写完后不能只跑通一条用例就宣告完成按下面四步验证保证交给同事或CI都能稳定执行断网可跑fixture不依赖外部登录态不依赖远程配置服务浏览器驱动和被测应用都在本地或内网可达断外网后全套用例仍能执行。连续重复同一组用例至少连跑3次结果一致。观察reports/目录是否有新增失败截图出现偶发失败时要排查等待超时配置而不是直接改代码。单点改版故意改一个定位器字段确认只有对应的页面对象类需要变更测试用例文件零改动。如果用例也跟着改说明定位器泄漏到了业务层。独立可跑用pytest tests/test_login.py单独执行整个文件不依赖其他用例先跑或后跑不依赖执行顺序每个用例都有独立的浏览器生命周期。最后一步执行时重点看fixture有没有正确起停浏览器实例以及框架里有没有残留跨用例的全局状态。本文还有配套的精品资源点击获取