Python+Selenium+Pytest自动化测试框架:三层封装与PO模式实战

📅 2026/8/2 10:45:45
Python+Selenium+Pytest自动化测试框架:三层封装与PO模式实战
1. 项目概述为什么我们需要一个“简单”的封装做自动化测试的朋友尤其是从手工测试转过来的可能都有过类似的经历一开始兴致勃勃地写脚本用Selenium的find_element_by_id、find_element_by_xpath一个个定位元素用time.sleep硬等页面加载。一个简单的登录测试代码可能就几十行。但随着用例越来越多你会发现代码里充斥着重复的定位语句、混乱的等待逻辑一旦页面元素ID变了你得满世界去改脚本维护成本指数级上升。这时候一个清晰的念头就会冒出来我得把这些重复的东西封装起来。这个项目标题“PythonSeleniumPytest自动化测试简单封装demo”精准地戳中了这个痛点。它不是一个要造火箭的复杂框架而是一个“简单封装”的示范。这里的“简单”恰恰是关键——它意味着可落地、易理解、能快速在你的项目中用起来。核心目标就三个降低代码冗余、提升用例可读性、增强框架可维护性。通过将Selenium的原生API进行二次封装并融入Pytest这个强大的测试执行器我们就能搭建一个既轻量又专业的自动化测试脚手架。我见过很多团队一开始就追求大而全的框架引入了各种设计模式和复杂的配置结果项目还没跑起来团队成员先被绕晕了。这个demo的价值在于它展示了一条从混乱脚本到有序框架的最短路径。无论你是刚接触UI自动化的新手还是想优化现有脚本结构的测试开发这个封装思路都能给你提供一个即插即用的蓝本。接下来我会带你一步步拆解这个封装的每一个核心环节并分享我在实际项目中踩过的坑和总结的技巧。2. 核心设计思路三层架构与PO模式的精简实践一个健壮的自动化测试框架其核心在于清晰的分层。在这个简单的封装demo中我们实际上践行了一个精简版的“三层架构”并结合了Page ObjectPO模式的思想。它不是教科书式的复杂实现而是做了大量实用性的裁剪。2.1 基础层对Selenium原生API的“友好”包装Selenium WebDriver API功能强大但略显“原始”。直接使用会导致测试脚本中技术细节如定位方式、显式等待与业务逻辑如登录、搜索严重耦合。我们的封装第一件事就是创建一个BasePage类或者一个WebDriverUtils工具类。这个基础层的核心职责是统一初始化驱动管理WebDriverChrome、Firefox、Edge等的创建和销毁。我们会在这里处理浏览器选项比如无头模式、禁用沙箱、设置下载路径等。封装元素定位与操作将find_element、click、send_keys等操作封装成更语义化的方法。例如封装一个find方法内部自动处理显式等待确保元素在可交互状态时才返回。集中处理等待彻底告别time.sleep。使用Selenium提供的WebDriverWait和expected_conditions封装几种常见的等待条件如元素可见、可点击、存在等。提供常用公共方法如滚动到元素、切换窗口/iframe、执行JavaScript、截图等。这些方法每个页面都可能用到放在基础层最合适。这样做的最大好处是当上游Selenium库更新或者我们想替换某个底层操作时只需要修改基础层这一个地方所有上层的页面类和测试用例都无需改动。2.2 页面层Page Object模式的轻量化落地PO模式是UI自动化的最佳实践之一其核心思想是将页面封装成对象页面上的元素就是对象的属性页面上的操作就是对象的方法。在我们的demo中页面层继承自基础层。例如对于一个登录页面LoginPage属性用户名输入框、密码输入框、登录按钮、错误提示框的定位器推荐使用By类如By.ID,By.XPATH。方法login(username, password)。这个方法内部会调用基础层封装好的send_keys和click完成输入和点击操作。这里的一个关键技巧是不要在页面方法内部进行断言。页面对象只负责行为做什么不负责验证对不对。断言是测试用例的职责。这样保持了页面对象的纯洁性它可以在不同的测试场景正常登录、异常登录中被复用。2.3 测试层Pytest驱动的高效用例组织这是封装demo的“大脑”。我们使用Pytest而不是Python自带的unittest是因为Pytest更简洁、功能更强大。在这一层我们主要做四件事用例编写测试用例函数以test_开头。在用例中调用页面对象的方法来完成业务流程然后使用Pytest的assert语句或丰富的断言插件进行验证。夹具管理Pytest的fixture是神器。我们会创建核心的fixture例如driver用来初始化和清理WebDriverlogin_page用来提供已初始化的登录页面对象。通过pytest.fixture装饰器我们可以轻松实现用例级别的前置后置操作以及跨模块的共享。数据驱动使用pytest.mark.parametrize装饰器轻松实现多组测试数据的驱动。将测试数据从用例逻辑中分离出来是提升框架可维护性的重要一步。执行控制利用Pytest的标记mark功能如pytest.mark.smoke冒烟测试可以灵活地选择执行部分用例。三层之间是单向依赖关系测试层 - 页面层 - 基础层。这种结构确保了代码的清晰度和可测试性。3. 环境准备与核心工具链详解工欲善其事必先利其器。一个稳定的环境是自动化测试的基石。这里我会详细列出所需组件并解释每个选择的理由特别是针对不同操作系统和浏览器版本的常见坑点。3.1 Python环境与包管理Python版本选择推荐使用Python 3.8或3.9。这两个版本是当前众多第三方库兼容性最好的“甜点区”。Python 3.10虽然新但偶尔会遇到一些科学计算或底层库的兼容性问题。对于自动化测试这种追求稳定性的项目保守一点更稳妥。包管理工具强烈建议使用pip配合virtualenv或venv创建虚拟环境。绝对不要在系统全局Python环境里直接安装项目依赖。为每个自动化项目创建独立的虚拟环境可以完美解决依赖冲突问题。核心依赖安装在项目根目录下创建一个requirements.txt文件内容如下selenium4.15.0 pytest7.4.4 pytest-html4.1.1 pytest-xdist3.5.0 webdriver-manager4.0.1 allure-pytest2.13.2使用命令pip install -r requirements.txt一键安装。selenium: 核心浏览器自动化库。pytest: 测试框架本体。pytest-html: 生成HTML测试报告直观展示结果。pytest-xdist: 实现测试用例并行执行大幅缩短测试总时长。webdriver-manager:重点推荐。这个库可以自动下载、匹配和启动对应版本的浏览器驱动如chromedriver, geckodriver。从此告别手动下载驱动、配置环境变量的繁琐操作也解决了浏览器升级后驱动不匹配的经典难题。allure-pytest: 用于生成Allure报告。Allure报告比HTML报告更美观、信息维度更丰富适合在团队中展示测试结果。3.2 浏览器与驱动管理的“坑”与“解”这是新手最容易卡住的地方。以最主流的Chrome浏览器为例。传统手动方式不推荐但需了解查看Chrome浏览器版本帮助 - 关于Google Chrome。访问ChromeDriver官网下载版本号完全匹配的驱动。将驱动可执行文件放在系统PATH路径下或直接在代码中指定路径。痛点浏览器自动更新后驱动版本不匹配导致脚本报错SessionNotCreatedException。现代解决方案使用webdriver-manager 在代码中初始化WebDriver时from selenium import webdriver from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager # 自动管理驱动 service Service(ChromeDriverManager().install()) driver webdriver.Chrome(serviceservice)webdriver-manager会自动检查本地已有驱动版本如果与当前浏览器不匹配或不存在则会从镜像站下载正确的版本。一劳永逸。关于Edge浏览器如果你使用Microsoft Edge原理相同。确保你的Edge浏览器已更新到较新版本Chromium内核。使用webdriver_manager.microsoft中的EdgeChromiumDriverManager即可。网络上搜索的“要在edge启用一个扩展程序”这类问题通常是因为使用了旧版的、非Chromium内核的Edge驱动或者驱动版本严重不匹配。坚持使用webdriver-manager和Chromium版Edge可以避开99%的驱动问题。3.3 IDE与项目结构IDE选择VS Code是绝佳选择轻量且插件生态丰富。必备插件Python提供语法高亮、智能提示、调试支持。Pytest方便地在侧边栏发现和运行测试用例。其他如代码格式化、项目管理插件按需安装。项目结构规划一个清晰的结构是框架可维护性的前提。建议如下your_project/ ├── conftest.py # Pytest全局配置文件放置共享的fixture ├── requirements.txt # 项目依赖列表 ├── reports/ # 存放生成的测试报告HTML, Allure结果 ├── logs/ # 存放运行日志 ├── test_data/ # 存放测试数据文件JSON, YAML, CSV ├── page_objects/ # 页面对象层 │ ├── __init__.py │ ├── base_page.py # 基础页面封装类 │ ├── login_page.py # 登录页面 │ └── home_page.py # 主页 ├── common/ # 公共模块 │ ├── __init__.py │ ├── webdriver_utils.py # 也可将基础封装放这里 │ └── config_reader.py # 配置文件读取 ├── test_cases/ # 测试用例层 │ ├── __init__.py │ ├── test_login.py # 登录模块测试用例 │ └── test_search.py # 搜索模块测试用例 └── run_tests.py # 测试执行入口脚本可选这个结构将不同职责的代码物理分离符合之前的三层设计思路。4. 核心封装实现从BasePage到可用的Page Object现在我们进入最核心的编码环节。我会逐块解析代码并解释每一行背后的设计考量。4.1 BasePage打造稳健的操作基石在page_objects/base_page.py中我们创建所有页面对象的父类。from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.common.exceptions import TimeoutException, NoSuchElementException import logging import allure class BasePage: def __init__(self, driver): self.driver driver self.logger logging.getLogger(__name__) self.wait WebDriverWait(driver, 10) # 设置全局显式等待超时时间 def find_element(self, locator): 查找单个元素加入显式等待 try: self.logger.info(f正在查找元素: {locator}) element self.wait.until(EC.presence_of_element_located(locator)) # 滚动元素到视图中心避免元素被遮挡 self.driver.execute_script(arguments[0].scrollIntoView({block: center});, element) return element except TimeoutException: self.logger.error(f元素查找超时: {locator}) # 失败时自动截图并附加到Allure报告 allure.attach(self.driver.get_screenshot_as_png(), nameftimeout_{locator}, attachment_typeallure.attachment_type.PNG) raise def click(self, locator): 点击元素确保元素可点击 element self.wait.until(EC.element_to_be_clickable(locator)) element.click() self.logger.info(f已点击元素: {locator}) def input_text(self, locator, text): 输入文本先清空再输入 element self.find_element(locator) element.clear() element.send_keys(text) self.logger.info(f已在元素 {locator} 输入文本: {text}) def get_text(self, locator): 获取元素文本 element self.find_element(locator) return element.text def is_element_visible(self, locator, timeout5): 判断元素是否在指定时间内可见 try: WebDriverWait(self.driver, timeout).until(EC.visibility_of_element_located(locator)) return True except TimeoutException: return False关键点解析__init__中初始化WebDriverWait将显式等待对象作为实例属性避免在每个方法中重复创建。10秒是一个经验值对于大多数现代Web应用够用。find_element方法这是所有操作的基石。它使用EC.presence_of_element_located意思是“等待元素出现在DOM中”。注意出现在DOM中不代表一定可见或可点击所以对于点击操作我们单独封装了click方法使用EC.element_to_be_clickable条件。方法内还加入了自动滚动这是一个非常实用的技巧可以解决很多因元素不在视口内而导致的点击失败问题。异常处理与日志所有操作都包裹在try...except中并记录日志。在元素查找失败时除了记录错误日志还利用allure.attach自动截图并附加到测试报告中。这能极大地方便失败用例的调试——你不需要手动去翻截图报告里直接就有。is_element_visible方法这是一个辅助断言方法。在测试用例中我们经常需要断言某个元素如成功提示是否出现。这个方法返回布尔值非常便于在assert语句中使用。4.2 页面对象业务操作的封装以page_objects/login_page.py为例from selenium.webdriver.common.by import By from .base_page import BasePage class LoginPage(BasePage): # 定位器使用By类将定位方式和表达式放在一起更清晰 USERNAME_INPUT (By.ID, username) PASSWORD_INPUT (By.ID, password) LOGIN_BUTTON (By.XPATH, //button[typesubmit]) ERROR_MSG (By.CLASS_NAME, alert-error) def __init__(self, driver): super().__init__(driver) # 可以在这里添加页面特有的初始化逻辑比如访问登录页URL # self.driver.get(https://example.com/login) 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_message(self): 获取登录错误提示信息 if self.is_element_visible(self.ERROR_MSG): return self.get_text(self.ERROR_MSG) return None关键点解析定位器作为类属性这是PO模式的标志。将所有元素的定位器集中定义在类顶部一目了然。如果页面元素变了只需要改这一个地方。使用(By.ID, “value”)这样的元组形式是Selenium推荐的做法。业务方法login方法内部调用的是父类BasePage封装好的input_text和click。测试用例只需要调用page.login(“admin”, “123456”)完全看不到任何Selenium API的细节可读性极高。get_error_message方法这是一个“查询”性质的方法它返回页面的状态错误信息但不做断言。断言是测试用例的职责。这样设计使得页面对象可以在“登录成功”和“登录失败”等多种测试场景中被复用。4.3 Pytest Fixture驱动与页面的生命周期管理在项目根目录或test_cases目录下创建conftest.py这是Pytest的魔力所在。import pytest from selenium import webdriver from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager from page_objects.login_page import LoginPage pytest.fixture(scopefunction) def driver(): 提供WebDriver实例每个测试函数一个 # 配置浏览器选项 options webdriver.ChromeOptions() options.add_argument(--headless) # 无头模式不打开GUI适合CI环境 options.add_argument(--no-sandbox) options.add_argument(--disable-dev-shm-usage) options.add_argument(--disable-gpu) options.add_argument(--window-size1920,1080) service Service(ChromeDriverManager().install()) driver_instance webdriver.Chrome(serviceservice, optionsoptions) driver_instance.implicitly_wait(5) # 设置隐式等待作为显式等待的补充 yield driver_instance # 测试函数在此处执行 # 测试函数执行完毕后执行清理 driver_instance.quit() pytest.fixture(scopefunction) def login_page(driver): 提供已初始化的LoginPage实例 page LoginPage(driver) # 假设登录页URL实际项目中应从配置读取 driver.get(https://your-test-site.com/login) return page关键点解析pytest.fixture装饰器scope”function”表示这个fixture在每个测试函数开始前创建函数结束后销毁。这是最常用的范围确保测试之间的隔离。driverfixture这是核心fixture。它负责创建和销毁WebDriver。yield关键字是关键yield之前的代码是setup前置yield之后的代码是teardown后置。测试函数执行时使用的就是yield返回的driver_instance。浏览器选项示例中配置了无头模式这对于在服务器或持续集成CI环境中运行测试至关重要。--disable-dev-shm-usage和--no-sandbox是解决Linux/Docker环境中常见Chrome崩溃问题的经典参数。login_pagefixture它依赖于driverfixture。Pytest会自动处理这种依赖关系。这个fixture直接返回一个已经初始化并可能已跳转到对应URL的页面对象测试用例用起来非常方便。隐式等待implicitly_wait(5)设置了一个全局的隐式等待。它和显式等待的区别在于隐式等待是告诉WebDriver在查找任何元素时如果没立即找到就轮询等待最多5秒。显式等待是针对特定条件的等待。两者可以结合使用但显式等待通常更精确、更推荐。这里设置一个较短的隐式等待可以作为找不到元素时的最后一道宽容防线。5. 测试用例编写与数据驱动实战有了稳固的基础设施编写测试用例就变成了一件愉快而高效的事情。5.1 一个完整的测试用例示例在test_cases/test_login.py中import pytest import allure allure.feature(登录模块) class TestLogin: 登录功能测试集 allure.story(用户登录成功) allure.title(使用正确的用户名和密码可以登录成功) def test_login_success(self, login_page): 测试正常登录流程 with allure.step(步骤1: 输入正确的用户名和密码): login_page.login(admin, correct_password) with allure.step(步骤2: 验证登录后跳转到首页): # 假设登录成功会跳转到首页首页有用户菜单元素 # 这里需要引入HomePage我们假设其有一个定位器 USER_MENU from page_objects.home_page import HomePage home_page HomePage(login_page.driver) # 使用同一个driver assert home_page.is_element_visible(HomePage.USER_MENU), 登录成功后未找到用户菜单登录可能失败 with allure.step(步骤3: 验证登录用户名显示正确): # 假设首页用户菜单显示用户名 actual_name home_page.get_text(HomePage.USER_MENU) assert admin in actual_name, f显示的用户名不正确期望包含admin实际是{actual_name} allure.story(用户登录失败) allure.title(使用错误的密码登录会显示错误提示) pytest.mark.parametrize(username, password, expected_error, [ (admin, wrong_pass, 用户名或密码错误), (, some_pass, 用户名不能为空), (admin, , 密码不能为空), ]) def test_login_failure(self, login_page, username, password, expected_error): 测试登录失败的各种情况 - 数据驱动 with allure.step(f步骤1: 输入异常组合 (用户: {username}, 密码: {password})): login_page.login(username, password) with allure.step(步骤2: 验证页面显示了预期的错误信息): actual_error login_page.get_error_message() # 使用pytest的assert进行断言失败信息更清晰 assert actual_error expected_error, f错误信息不匹配。期望: {expected_error} 实际: {actual_error}关键点解析Allure装饰器allure.feature,allure.story,allure.title用于在Allure报告中更好地组织和展示测试用例。with allure.step用于描述测试步骤让报告读起来像一个测试剧本非常直观。测试类与用例组织使用类TestLogin来组织同一模块的测试。测试方法以test_开头。方法名应具有描述性。断言使用Python原生的assert语句Pytest会对其进行增强在失败时提供详细的上下文信息。断言应清晰描述期望结果。test_login_failure方法展示了Pytest强大的数据驱动功能。pytest.mark.parametrize装饰器允许我们用一个测试方法运行多组数据。它接收三个参数参数名字符串、一个列表或元组的列表。Pytest会为列表中的每一组数据运行一次这个测试方法并将数据解包给方法参数。这极大地减少了代码重复。5.2 测试数据的外部化管理当测试数据很多时将其放在代码里会显得臃肿。最佳实践是将数据放到外部文件中如JSON或YAML。创建一个test_data/login_data.yamlfailure_cases: - username: admin password: wrong expected_error: 用户名或密码错误 - username: password: some_pass expected_error: 用户名不能为空 - username: admin password: expected_error: 密码不能为空 success_cases: - username: admin password: correct_password expected_name: admin然后在测试用例中读取import yaml import pytest def load_login_data(): with open(test_data/login_data.yaml, r, encodingutf-8) as f: data yaml.safe_load(f) return data login_data load_login_data() class TestLoginExternalData: pytest.mark.parametrize(case, login_data[failure_cases]) def test_login_failure_with_external_data(self, login_page, case): login_page.login(case[username], case[password]) assert login_page.get_error_message() case[expected_error]这种方式使得测试数据与代码分离非技术人员如产品经理也可以维护测试数据。6. 高级技巧与实战避坑指南掌握了基础封装和用例编写后一些高级技巧和实战中的“坑”能让你框架的稳定性和效率再上一个台阶。6.1 等待策略告别“玄学”失败UI自动化最大的不稳定因素就是“等待”。除了我们封装的显式等待还有几个高级技巧自定义等待条件有时候内置的expected_conditions不够用。比如等待某个元素不再存在如加载动画消失。def element_not_present(driver, locator): 自定义等待条件等待元素不存在于DOM try: WebDriverWait(driver, 0).until(EC.presence_of_element_located(locator)) return False except TimeoutException: return True # 使用 self.wait.until(lambda d: element_not_present(d, (By.ID, loading-spinner)))重试机制对于某些非必然的偶发失败如网络波动导致的点击无响应可以给操作加上重试装饰器。import time from functools import wraps def retry_on_failure(max_attempts3, delay1): def decorator(func): wraps(func) def wrapper(*args, **kwargs): for attempt in range(max_attempts): try: return func(*args, **kwargs) except Exception as e: if attempt max_attempts - 1: raise print(f尝试 {func.__name__} 失败第 {attempt 1} 次重试... 错误: {e}) time.sleep(delay) return None return wrapper return decorator class BasePage: retry_on_failure(max_attempts2) def click(self, locator): # ... 原有的click逻辑6.2 测试报告与日志问题定位的利器清晰的报告和日志是快速排查问题的关键。生成HTML报告运行测试时使用命令pytest --htmlreports/report.html。可以在conftest.py中配置更详细的报告选项。生成Allure报告运行测试pytest --alluredir./allure-results生成报告allure serve ./allure-results(需要先安装Allure命令行工具)Allure报告提供了历史趋势、环境信息、用例步骤、截图附件等非常强大。结构化日志在conftest.py中配置日志将不同级别的日志输出到文件和控制台。import logging def pytest_configure(config): # 配置根日志记录器 logging.basicConfig( levellogging.INFO, format%(asctime)s - %(name)s - %(levelname)s - %(message)s, handlers[ logging.FileHandler(logs/automation.log), logging.StreamHandler() ] )6.3 并行测试与测试筛选当用例成百上千时串行执行太慢。使用pytest-xdist并行执行安装pytest-xdist后使用pytest -n auto命令。auto会自动根据你的CPU核心数启动worker进程。并行时要注意测试之间的独立性避免共享浏览器或全局状态。使用标记筛选用例给用例打上标记如pytest.mark.smoke冒烟测试pytest.mark.regression回归测试。运行时使用pytest -m smoke只执行冒烟用例。6.4 常见“坑”与解决方案元素定位不到可能原因1动态ID或类名。解决方案使用更稳定的定位方式如By.XPATH结合相对路径、文本内容(text())或属性(contains(class, ‘partial-class’))。可能原因2元素在iframe或shadow DOM内。解决方案使用driver.switch_to.frame()切换到iframe对于Shadow DOM需要使用JavaScript来穿透。可能原因3页面未完全加载。解决方案在find_element前增加更智能的等待比如等待某个标志性元素出现。点击不生效可能原因元素被遮挡。解决方案在点击前使用ActionChains移动鼠标到元素或使用JavaScript直接执行点击driver.execute_script(“arguments[0].click();”, element)。输入框内容无法清空可能原因某些前端框架如React, Vue绑定了特殊事件。element.clear()可能无法触发这些事件。解决方案先全选再删除element.send_keys(Keys.CONTROL “a”); element.send_keys(Keys.DELETE)。浏览器Cookie/缓存干扰可能原因用例之间未完全隔离。解决方案在每个测试开始前使用driver.delete_all_cookies()和driver.execute_script(“window.localStorage.clear();”)清理状态。更彻底的是为每个测试使用全新的浏览器实例通过scope”function”的fixture实现。这个简单的封装Demo其价值不在于代码量而在于它确立了一个清晰、可扩展的架构模式。从混乱的线性脚本到分层的PO模式从硬编码等待到智能的显式等待从手动管理驱动到自动化工具处理每一步都朝着更稳定、更易维护、更高效的方向迈进。在实际项目中你可以在此基础上继续扩展比如加入API测试的封装、数据库断言、邮件通知、与CI/CD工具如Jenkins, GitLab CI的集成等逐步搭建起属于你自己团队的自动化测试体系。记住好的框架是演化出来的而不是一开始就设计好的。从这个“简单封装”开始让你的自动化测试之旅走得更稳、更远。