从零搭建Python+Selenium+Pytest Web自动化测试框架实战指南
1. 项目概述为什么我们需要一个自己的Web自动化测试框架如果你是一名测试工程师或者是一名正在学习Python、希望提升工作效率的开发人员那么“自动化测试”这个词对你来说一定不陌生。尤其是在Web应用开发中每次功能更新、版本迭代都需要进行大量的回归测试手动点击、输入、验证不仅耗时耗力还容易因为疲劳而出错。这时候一个稳定、可维护的自动化测试框架就成了团队的“刚需”。Python Selenium 的组合几乎是这个领域最经典、最受欢迎的解决方案。Python语法简洁生态丰富Selenium则提供了操控浏览器的强大能力能模拟真实用户的所有操作。但直接上手写Selenium脚本很快就会遇到问题脚本散乱、难以复用、环境依赖复杂、报告不直观、失败后难以排查……这些痛点催生了“搭建框架”的需求。一个框架的核心价值就是将零散的脚本、配置、数据和报告组织起来形成一套标准化的工程实践。它不是为了炫技而是为了解决实际问题提升脚本编写效率、增强测试用例的可维护性、方便团队协作、并产出清晰的结果。所以今天我们不谈空洞的理论直接动手从零开始搭建一个结构清晰、即拿即用的Web自动化测试框架。这个框架将包含用例管理、页面对象模型、数据驱动、日志记录、测试报告生成等核心模块。无论你是想系统学习自动化测试还是急需一个项目模板来启动工作这篇文章都将提供一条清晰的路径和大量“踩坑”后的经验。2. 框架核心设计与思路拆解在动手写代码之前我们先要搞清楚我们要建一个什么样的房子以及为什么这么设计。一个好的框架设计能在后续的开发和维护中节省大量时间。2.1 主流测试框架模式选择市面上常见的Python测试框架有unittestPython标准库、pytest和nose2。对于自动化测试pytest因其强大的插件生态、简洁的语法和灵活的夹具fixture系统已成为事实上的标准。为什么选择pytest断言更智能unittest需要使用self.assertEqual()等方法而pytest直接使用assert语句失败时会给出非常详细的差异对比排查问题一目了然。夹具Fixture机制这是pytest的灵魂。我们可以用pytest.fixture装饰器来定义测试前置和后置操作比如启动浏览器、登录、清理数据等。这些夹具可以灵活地作用在函数、类、模块甚至整个会话级别管理测试生命周期异常方便。参数化测试pytest.mark.parametrize装饰器可以轻松实现数据驱动测试用多组数据运行同一个测试用例无需写多个重复函数。丰富的插件有生成HTML报告的pytest-html控制用例执行顺序的pytest-ordering多线程运行的pytest-xdist等生态非常完善。发现规则简单默认查找以test_开头或结尾的文件、函数、方法符合大多数人的命名习惯。因此我们的框架将以pytest作为测试组织和执行的核心。2.2 页面对象模型让代码更“耐读”这是自动化测试中最重要的设计模式没有之一。它的核心思想是将测试脚本做什么与页面细节怎么做分离开。传统脚本的问题如果你把查找元素、输入文本、点击按钮的所有代码都写在测试用例里那么当页面UI发生微小变动时比如一个按钮的ID变了你需要修改所有相关的测试用例维护成本是灾难性的。POM的解决方案为每一个Web页面或页面中的一个重要组件创建一个对应的类。这个类中只包含两样东西页面元素定位器将所有用到的元素如输入框、按钮的定位方式ID、XPath、CSS Selector等定义为这个类的属性。页面操作方法将对元素的操作如输入、点击、获取文本封装成这个类的方法。这样测试用例就变得非常简洁和易读它只需要调用页面对象的方法而不关心具体如何定位和操作。当页面元素变化时你只需要修改对应的页面对象类即可所有测试用例都无需改动。2.3 框架目录结构规划清晰的目录结构是项目可维护性的基础。我推荐以下结构这也是业界常见的实践your_automation_framework/ ├── configs/ # 配置文件目录 │ ├── __init__.py │ └── config.yaml # 或 config.ini存放URL、浏览器类型、超时时间等 ├── data/ # 测试数据目录 │ ├── __init__.py │ └── test_data.csv # 或 .json, .yaml 文件 ├── logs/ # 日志文件目录运行时生成 ├── reports/ # 测试报告目录运行时生成 ├── page_objects/ # 页面对象目录 │ ├── __init__.py │ ├── base_page.py # 所有页面对象的基类 │ ├── login_page.py # 登录页面 │ └── home_page.py # 主页 ├── test_cases/ # 测试用例目录 │ ├── __init__.py │ ├── conftest.py # pytest共享夹具配置 │ └── test_login.py # 登录测试用例 ├── utils/ # 工具函数目录 │ ├── __init__.py │ ├── driver_manager.py # 浏览器驱动管理 │ ├── logger.py # 日志记录器 │ └── common_actions.py # 通用操作封装 ├── requirements.txt # Python依赖包列表 └── pytest.ini # pytest配置文件这个结构将不同职责的代码分门别类新人上手也能很快找到该修改哪里。3. 环境搭建与核心工具链配置工欲善其事必先利其器。这一步虽然基础但很多坑都埋在这里。3.1 Python环境与虚拟环境首先确保你安装了Python建议3.8及以上版本。安装后第一件事不是直接pip install而是创建虚拟环境。为什么用虚拟环境它可以为当前项目创建一个独立的Python包安装空间避免不同项目之间的依赖冲突。这是Python项目开发的“标配”。如何操作# 在项目根目录下 python -m venv venv # 激活虚拟环境 # Windows: venv\Scripts\activate # macOS/Linux: source venv/bin/activate激活后命令行提示符前会出现(venv)标识。3.2 依赖包安装与浏览器驱动在虚拟环境中安装核心依赖。将以下内容保存到requirements.txt文件中pytest7.0.0 selenium4.0.0 pytest-html3.0.0 pytest-xdist2.0.0 PyYAML6.0 webdriver-manager3.0.0然后执行安装pip install -r requirements.txt这里重点说一下webdriver-manager和浏览器驱动。以前我们需要手动下载ChromeDriver、GeckoDriver并放到系统PATH里版本不匹配就会报错非常麻烦。webdriver-manager的妙用这个库可以自动检测你本地安装的浏览器版本并下载匹配的驱动。在代码中我们不再需要指定驱动路径。from selenium import webdriver from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager # 传统方式需要手动管理驱动 # driver webdriver.Chrome(executable_path/path/to/chromedriver) # 使用 webdriver-manager推荐 service Service(ChromeDriverManager().install()) driver webdriver.Chrome(serviceservice)这样无论团队成员使用什么版本的Chrome代码都能自动适配极大降低了环境配置成本。注意虽然webdriver-manager很方便但在一些严格的内网环境或CI/CD流水线中可能无法访问外网下载驱动。此时可以采用“将驱动打包在项目内”或“使用公司内部镜像源”的备选方案。我们的框架设计需要考虑这种灵活性在配置文件中可以设置一个开关决定是自动下载还是使用指定路径的驱动。3.3 IDE配置与调试技巧我强烈推荐使用VSCode或PyCharm。以VSCode为例你需要安装Python扩展。关键配置在于.vscode/launch.json用于调试。调试pytest用例在VSCode中可以配置如下启动项方便你打断点调试单个测试文件甚至单个测试函数。{ version: 0.2.0, configurations: [ { name: Python: Debug Current Test, type: python, request: launch, program: ${file}, pytestEnabled: true, args: [ -v, -s, ${file} ], console: integratedTerminal } ] }一个实用技巧在测试脚本中有时需要暂停以观察页面状态。除了打断点你可以在代码中插入input(“按回车继续...”)或使用time.sleep(seconds)。但在正式脚本中务必移除或替换为智能等待否则会严重影响执行效率。4. 核心模块实现从基类到页面对象现在我们开始编写框架的核心代码。我们将采用自底向上的方式先构建稳固的基础设施。4.1 工具类封装驱动管理与日志在utils/driver_manager.py中我们封装浏览器的创建和退出逻辑。# utils/driver_manager.py from selenium import webdriver from selenium.webdriver.chrome.service import Service from selenium.webdriver.firefox.service import Service as FirefoxService from webdriver_manager.chrome import ChromeDriverManager from webdriver_manager.firefox import GeckoDriverManager from configs.config import Config # 假设配置从config.py读取 class DriverManager: _driver None classmethod def get_driver(cls): 获取WebDriver单例实例 if cls._driver is None: browser Config.BROWSER.lower() if browser chrome: options webdriver.ChromeOptions() # 添加常用选项如无头模式、忽略证书错误等 if Config.HEADLESS: options.add_argument(--headless) options.add_argument(--ignore-certificate-errors) options.add_argument(--disable-gpu) options.add_argument(--no-sandbox) options.add_argument(--disable-dev-shm-usage) # 防止被一些网站识别为自动化脚本非100%有效 options.add_experimental_option(excludeSwitches, [enable-automation]) options.add_experimental_option(useAutomationExtension, False) service Service(ChromeDriverManager().install()) cls._driver webdriver.Chrome(serviceservice, optionsoptions) elif browser firefox: # ... 类似地配置Firefox pass else: raise ValueError(fUnsupported browser: {browser}) cls._driver.implicitly_wait(Config.IMPLICIT_WAIT_TIME) cls._driver.maximize_window() return cls._driver classmethod def quit_driver(cls): 退出WebDriver并清理实例 if cls._driver: cls._driver.quit() cls._driver None这里采用了单例模式确保在整个测试运行期间如果需要我们可以获取到同一个浏览器实例。同时将浏览器类型、是否无头模式等配置化。在utils/logger.py中我们配置日志这对于排查线上CI运行的失败用例至关重要。# utils/logger.py import logging import os from datetime import datetime def setup_logger(name__name__, log_levellogging.INFO): 配置并返回一个日志记录器 # 创建日志记录器 logger logging.getLogger(name) logger.setLevel(log_level) # 避免重复添加处理器 if logger.handlers: return logger # 创建控制台处理器 ch logging.StreamHandler() ch.setLevel(log_level) # 创建文件处理器 log_dir logs os.makedirs(log_dir, exist_okTrue) log_file os.path.join(log_dir, fautomation_{datetime.now().strftime(%Y%m%d)}.log) fh logging.FileHandler(log_file, encodingutf-8) fh.setLevel(logging.INFO) # 创建格式化器 formatter logging.Formatter(%(asctime)s - %(name)s - %(levelname)s - %(message)s) ch.setFormatter(formatter) fh.setFormatter(formatter) # 添加处理器到记录器 logger.addHandler(ch) logger.addHandler(fh) return logger # 创建一个全局可用的日志记录器实例 log setup_logger()4.2 页面对象基类设计这是POM模式的地基。在page_objects/base_page.py中我们封装所有页面对象共用的方法。# 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 from utils.driver_manager import DriverManager from utils.logger import log class BasePage: 所有页面对象的基类 def __init__(self, driverNone): self.driver driver or DriverManager.get_driver() self.wait WebDriverWait(self.driver, timeout10, poll_frequency0.5) def find_element(self, locator): 查找单个元素加入显式等待和日志 try: log.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: log.error(f查找元素超时: {locator}) # 这里可以截图方便排查 self.take_screenshot(felement_not_found_{locator[1]}) raise def click(self, locator): 点击元素 element self.find_element(locator) try: element.click() log.info(f已点击元素: {locator}) except Exception as e: log.error(f点击元素失败 {locator}: {e}) raise def input_text(self, locator, text): 向输入框输入文本先清空 element self.find_element(locator) element.clear() element.send_keys(text) log.info(f已在元素 {locator} 输入文本: {text}) def get_text(self, locator): 获取元素的文本内容 element self.find_element(locator) text element.text log.info(f获取到元素 {locator} 的文本: {text}) return 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 def take_screenshot(self, name): 截图并保存到reports/screenshots目录 import os screenshot_dir os.path.join(reports, screenshots) os.makedirs(screenshot_dir, exist_okTrue) filepath os.path.join(screenshot_dir, f{name}_{int(time.time())}.png) self.driver.save_screenshot(filepath) log.info(f截图已保存: {filepath}) return filepath基类封装了最常用的操作并加入了等待、日志和截图让后续的页面对象类编写更简洁、健壮。4.3 具体页面对象实现以登录页面为例。假设我们有一个简单的登录页用户名输入框ID是username密码输入框ID是password登录按钮ID是submit。# page_objects/login_page.py from selenium.webdriver.common.by import By from page_objects.base_page import BasePage from utils.logger import log class LoginPage(BasePage): 登录页面对象 # 页面元素定位器 USERNAME_INPUT (By.ID, username) PASSWORD_INPUT (By.ID, password) SUBMIT_BUTTON (By.ID, submit) ERROR_MSG_SPAN (By.CLASS_NAME, error-message) def __init__(self, driverNone): super().__init__(driver) self.url https://your-app.com/login # 应从配置读取 def open(self): 打开登录页面 self.driver.get(self.url) log.info(f已打开登录页面: {self.url}) return self def login(self, username, password): 执行登录操作 log.info(f尝试登录用户名: {username}) self.input_text(self.USERNAME_INPUT, username) self.input_text(self.PASSWORD_INPUT, password) self.click(self.SUBMIT_BUTTON) # 通常登录后页面会跳转这里可以返回下一个页面的对象比如HomePage from page_objects.home_page import HomePage return HomePage(self.driver) def get_error_message(self): 获取登录错误提示信息 if self.is_element_visible(self.ERROR_MSG_SPAN): return self.get_text(self.ERROR_MSG_SPAN) return None看测试用例现在只需要关心用哪个用户名和密码登录。至于怎么找到输入框、怎么点击都封装在了LoginPage类里。5. 测试用例编写与pytest夹具运用有了强大的页面对象编写测试用例就变成了一件愉快的事情。我们结合pytest的夹具来管理测试环境。5.1 共享夹具配置在test_cases/conftest.py文件中我们可以定义作用于整个测试目录的夹具。# test_cases/conftest.py import pytest from utils.driver_manager import DriverManager from utils.logger import log pytest.fixture(scopesession) def driver(): 会话级别的夹具整个测试会话只启动一次浏览器 log.info(正在启动浏览器...) driver_instance DriverManager.get_driver() yield driver_instance log.info(测试会话结束正在退出浏览器...) DriverManager.quit_driver() pytest.fixture(scopefunction) def login_page(driver): 函数级别的夹具每个测试函数都获得一个干净的登录页面 from page_objects.login_page import LoginPage page LoginPage(driver) page.open() yield page # 每个测试函数后可以清理cookie回到初始状态 driver.delete_all_cookies() log.info(已清理浏览器Cookies。)scope”session”这个夹具在整个pytest执行过程中只会执行一次。yield之前的代码是前置条件启动浏览器yield返回的是driver对象供测试使用yield之后的代码是后置清理退出浏览器。这保证了所有用例在一个浏览器窗口内顺序执行效率高。scope”function”这是默认范围。每个测试函数都会执行一次这个夹具。这里我们让每个测试函数都从一个新打开的登录页面开始并清理Cookies确保测试之间的独立性。5.2 编写第一个测试用例现在在test_cases/test_login.py中编写测试。# test_cases/test_login.py import pytest import allure # 可选用于生成更漂亮的Allure报告 from data.test_data import TestData # 假设测试数据从这里来 class TestLogin: 登录功能测试集 pytest.mark.parametrize(username, password, expected, [ (TestData.VALID_USER, TestData.VALID_PWD, success), (wrong_user, TestData.VALID_PWD, failure), (TestData.VALID_USER, wrong_pwd, failure), (, , failure), ]) def test_login_with_different_credentials(self, login_page, username, password, expected): 数据驱动测试使用多组用户名密码组合测试登录功能 if expected success: # 期望登录成功应跳转到主页 home_page login_page.login(username, password) # 断言检查主页的某个特定元素是否存在比如用户头像 assert home_page.is_user_avatar_displayed(), f使用 {username} 登录后未成功跳转到主页 print(f测试通过: 用户 {username} 登录成功。) else: # 期望登录失败应停留在登录页并看到错误信息 login_page.login(username, password) error_msg login_page.get_error_message() # 断言错误信息应该存在且不为空 assert error_msg is not None and len(error_msg) 0, f使用错误凭证 {username} 登录未显示预期的错误提示 print(f测试通过: 用户 {username} 登录失败提示 {error_msg}。) def test_login_with_remember_me(self, login_page): 测试‘记住我’功能 # 假设登录页有一个记住我复选框 remember_me_checkbox (By.ID, remember-me) login_page.click(remember_me_checkbox) home_page login_page.login(TestData.VALID_USER, TestData.VALID_PWD) # 退出再重新打开登录页检查用户名是否自动填充 # ... 具体断言逻辑省略 assert True这个测试类展示了两个关键点数据驱动使用pytest.mark.parametrize装饰器一个测试函数可以运行多次每次使用不同的测试数据。这比写多个几乎相同的函数要优雅和高效得多。夹具注入测试函数的参数login_page会自动匹配并调用我们在conftest.py中定义的login_page夹具从而获得一个已经打开登录页面的LoginPage对象。6. 测试执行、报告生成与高级技巧框架搭好了用例写好了最后一步就是运行它并得到一份漂亮的报告。6.1 使用pytest.ini进行配置在项目根目录创建pytest.ini文件统一配置pytest的运行行为。[pytest] # 指定测试文件的位置 testpaths test_cases # 自动发现测试文件的模式 python_files test_*.py python_classes Test* python_functions test_* # 添加命令行默认参数 addopts -v --htmlreports/report.html --self-contained-html # 设置日志级别 log_cli true log_cli_level INFO log_file logs/pytest_run.log log_file_level INFO-v: 输出详细信息。--htmlreports/report.html: 使用pytest-html插件生成HTML格式的测试报告。--self-contained-html: 将CSS样式内嵌到HTML中生成单个文件方便分享。现在在项目根目录下只需要运行一个简单的命令pytestpytest会自动根据pytest.ini的配置运行test_cases目录下所有以test_开头的文件并生成HTML报告到reports/report.html。6.2 解读HTML测试报告打开生成的report.html你会看到一个清晰的总结页面包括环境信息Python版本、pytest版本、平台等。测试结果摘要通过、失败、跳过、错误的用例数量及比例。详细的测试结果列表每个测试用例的名称、状态、耗时。点击可以展开查看该用例的print输出和日志如果配置了。失败用例的追溯信息如果用例失败会显示完整的错误堆栈跟踪直接定位到出错的代码行并附上失败时的截图如果我们在基类的异常处理中实现了截图功能。这份报告是向团队展示测试结果、分析失败原因的有力工具。6.3 高级技巧与避坑指南在实际项目中你肯定会遇到更多挑战。这里分享几个关键经验等待的艺术Selenium操作网页最大的不稳定因素就是“等待”。不要无脑用time.sleep隐式等待driver.implicitly_wait(10)设置一个全局的超时时间在查找元素时如果元素没有立即出现WebDriver会轮询查找直到超时。它只对find_element这类查找操作有效。显式等待使用WebDriverWait和expected_conditions如我们基类中所用。这是更精确、更推荐的方式。你可以等待元素可见、可点击、包含特定文本等。固定等待time.sleep只在极少数特殊情况下使用比如等待一个非Ajax的页面跳转或者模拟真人操作间隔。元素定位的稳定性优先使用ID和Name因为它们通常是唯一的且最稳定。其次是CSS Selector它比XPath性能更好语法也更简洁。XPath功能强大但脆弱前端结构一变就容易失效慎用尤其避免使用绝对路径以/开头。处理弹窗和iframe# 处理JavaScript Alert alert driver.switch_to.alert alert.accept() # 确认 alert.dismiss() # 取消 print(alert.text) # 获取文本 # 切换到iframe iframe_element driver.find_element(By.TAG_NAME, iframe) driver.switch_to.frame(iframe_element) # ... 在iframe内操作 driver.switch_to.default_content() # 切回主文档使用Page Factory模式可选如果你觉得在每个页面对象里写一堆(By.ID, ‘xxx’)很繁琐可以了解Selenium的Page Factory模式通过find_by装饰器声明元素但它有时不够灵活我个人更喜欢显式定义定位器的方式一目了然。在CI/CD中运行在Jenkins、GitLab CI等环境中通常需要在无头模式下运行测试。确保你的DriverManager支持headless模式配置。同时需要妥善处理测试失败时的截图和日志归档方便后续查看。搭建一个Web自动化测试框架就像搭建一套乐高。本文提供了最核心、最通用的“基础颗粒”和“搭建手册”。你可以以此为基础根据自己项目的实际需求添加更多的模块比如API测试集成使用requests库在UI测试前后验证接口状态。数据库断言使用pymysql或sqlalchemy在操作后直接查询数据库验证数据是否正确写入。更复杂的报告集成Allure框架生成图形化、可交互的测试报告。邮件通知测试完成后自动将报告发送给相关成员。记住框架的价值在于被使用和迭代。开始可能简单但随着项目成长它会和你一起变得强大。最关键的永远是写出稳定、可读、易维护的测试用例。现在就从你的第一个页面对象和第一个pytest用例开始吧。