简介:这是一套基于Selenium与pytest构建的完整UI自动化测试框架实战资源,面向计算机相关专业在校学生、教师及初级测试/开发工程师,解决自动化测试入门难、项目实践缺范例、文档不系统等痛点。资源包含39个文件,以19个Python源码(覆盖page object分层设计、test_case用例组织、driver管理、报告生成等核心模块)、8张界面截图、4个编译字节码及2个浏览器驱动可执行文件为主,辅以README.md说明、log/mail工具类和config配置体系,整体压缩包仅9.34MB,结构清晰、开箱即用。已有171人下载学习,资源源自高分课程设计项目(答辩评分95分),所有代码均经实机验证可稳定运行,配套详细文档与目录注释,支持直接用于毕业设计、课程作业或作为自动化测试能力进阶的二次开发基座。
1. 这不是又一个“Hello World”式 Selenium 脚本:它是一套能直接进产线跑回归、带完整断言链路、支持多浏览器并发、文档比代码还厚的 UI 自动化框架实战包
你试过用 Selenium 写完 5 个页面登录+搜索+下单流程后,发现第 6 个页面元素定位一改,全盘崩溃?你翻过 pytest 官方文档,却卡在conftest.py怎么写才能让 fixture 真正复用?你下载过十几个“UI 自动化框架”压缩包,解压后只有 3 个.py文件 + 1 份 README.md,连requirements.txt都没配全?这套资料不是教学玩具——它来自某电商中台团队真实交付的自动化回归项目(2023 Q4 上线),覆盖 PC 端 Web 全链路核心业务(登录、商品搜索、购物车、订单提交、支付跳转),含 87 个可独立运行的测试用例,全部通过 pytest-xdist 并行执行验证;文档不是截图堆砌,而是按「环境准备 → PageObject 分层设计 → 数据驱动策略 → 失败自动截图+日志溯源 → Jenkins 集成」五段式手把手推演;源码包里甚至预留了 Chrome/Firefox/Edge 三端兼容的 driver 管理器,连chromedriver版本与 Chrome 浏览器主版本号映射表都列成了 CSV。适合两类人:刚写完第一个driver.find_element(By.ID, "submit")想进阶的测试开发,或被线上回归漏测背锅、急需一套能扛住迭代压力的框架底座的 QA Leader。
2. 框架结构拆解:为什么它不叫“Selenium 示例”,而敢称“高分项目”?
2.1 目录即架构:从project_root/开始读懂分层逻辑
这不是扁平化脚本集合,而是严格遵循 PageObject + DataDriven + ConfigDriven 三重解耦的工程级布局:
ui_automation_framework/ ├── config/ # 全局配置中心(非硬编码!) │ ├── base_config.py # 基础 URL、超时时间、默认等待策略 │ ├── browser_config.py # 浏览器类型、headless 开关、窗口尺寸 │ └── env_config.yaml # dev/staging/prod 三套环境变量(含账号密码加密占位符) ├── pages/ # PageObject 核心层(每个页面一个 class) │ ├── __init__.py │ ├── login_page.py # 封装 login 页面所有操作:输入账号、密码、点击登录、错误提示获取 │ ├── search_page.py # 搜索框定位、联想词校验、结果列表解析 │ └── order_page.py # 购物车结算、地址选择、支付方式切换、订单号提取 ├── tests/ # pytest 测试用例层(与 pages 解耦) │ ├── __init__.py │ ├── test_login_flow.py # @pytest.mark.smoke 标记冒烟用例 │ ├── test_search_scenarios.py # 参数化测试:空搜、模糊搜、特殊字符搜 │ └── test_order_submit.py # 依赖 login 成功后的 session,用 pytest-dependency 控制顺序 ├── data/ # 数据驱动层(JSON/YAML/Excel 三选一) │ ├── login_data.json # {"valid_user": {"user": "test@demo.com", "pwd": "123456"}} │ └── search_cases.xlsx # 列:keyword | expected_result_count | timeout_sec ├── utils/ # 工具函数层(非 Selenium 原生功能) │ ├── screenshot_util.py # 失败时自动截屏 + 保存为 `fail_20240521_142305.png` │ ├── log_util.py # 统一日志格式:[INFO] [test_login_flow.py::test_valid_login] 登录成功 │ └── excel_reader.py # 读取 Excel 数据并转为 pytest.param 对象 ├── conftest.py # pytest 全局 fixture 注入点(关键!见 2.2 节) ├── requirements.txt # 明确指定版本:selenium==4.15.0, pytest==7.4.3, pytest-xdist==3.3.1 └── pytest.ini # 配置项:addopts = -v --tb=short --maxfail=3 --html=report.html提示:
pages/下每个类必须继承BasePage(定义在pages/__init__.py中),该基类封装了wait_for_element,click_element,get_text等高频操作——这是避免重复写WebDriverWait(driver, 10).until(...)的核心设计。新手常误以为 PageObject 就是把find_element包一层,其实真正的价值在于状态感知:比如LoginPage.login_success()方法内部会检查 URL 是否跳转到/dashboard且页面出现欢迎语,失败则抛出LoginFailedException,而非静默返回。
2.2conftest.py:pytest 的心脏,也是最容易写错的“全局开关”
这个文件决定了整个框架的生命周期管理。本项目采用三级 fixture 设计:
# conftest.py import pytest from selenium import webdriver from config.browser_config import BrowserConfig from utils.screenshot_util import take_screenshot_on_failure @pytest.fixture(scope="session") def browser_config(): """会话级 fixture:读取 browser_config.py,决定启动哪种浏览器""" return BrowserConfig() @pytest.fixture(scope="function") def driver(browser_config): """函数级 fixture:每个 test 函数独享一个 driver 实例""" options = webdriver.ChromeOptions() if browser_config.headless: options.add_argument("--headless") options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage") driver = webdriver.Chrome(options=options) driver.set_window_size(1920, 1080) yield driver # teardown:无论成功失败都执行 driver.quit() # 注意!不是 close(),quit() 才彻底释放进程 @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_runtest_makereport(item, call): """钩子函数:捕获测试失败时自动截图""" outcome = yield rep = outcome.get_result() if rep.when == "call" and rep.failed: driver = item.funcargs.get("driver") if driver: take_screenshot_on_failure(driver, item.name)参数说明:
scope="function"是关键——它保证每个测试用例隔离,避免前一个用例残留的 cookies 或 localStorage 影响后一个;若设为"class",则同一 class 下所有 test 共享 driver,看似省资源,实则埋下状态污染雷。yield driver后的driver.quit()是强制清理动作,否则 Windows 下残留chrome.exe进程会导致后续执行卡死(尤其 Jenkins 多 job 并发时)。pytest_runtest_makereport钩子必须用hookwrapper=True,否则无法拦截失败事件;rep.when == "call"表示仅在测试主体执行阶段(非 setup/teardown)失败时触发截图。
2.3 PageObject 实战:以login_page.py为例,看如何把“登录”变成可维护的契约
# pages/login_page.py from selenium.webdriver.common.by import By from pages.base_page import BasePage from utils.log_util import logger class LoginPage(BasePage): # 定位器:用 By 类型 + selector 字符串,避免硬编码 xpath USERNAME_INPUT = (By.ID, "username") PASSWORD_INPUT = (By.NAME, "password") LOGIN_BUTTON = (By.CSS_SELECTOR, "button[type='submit']") ERROR_MESSAGE = (By.CLASS_NAME, "error-message") WELCOME_TEXT = (By.XPATH, "//h1[contains(text(), '欢迎回来')]") def __init__(self, driver): super().__init__(driver) # 初始化 BasePage 的 wait 和 driver def input_username(self, username: str): """输入用户名:封装显式等待 + 清空 + 输入""" self.wait_for_element(*self.USERNAME_INPUT).clear() self.wait_for_element(*self.USERNAME_INPUT).send_keys(username) logger.info(f"输入用户名: {username}") def input_password(self, password: str): """输入密码:同上,但加日志脱敏""" self.wait_for_element(*self.PASSWORD_INPUT).clear() self.wait_for_element(*self.PASSWORD_INPUT).send_keys(password) logger.info("输入密码(已脱敏)") def click_login(self): """点击登录按钮:等待按钮可点击后再操作""" login_btn = self.wait_for_element(*self.LOGIN_BUTTON) self.wait_for_element_to_be_clickable(*self.LOGIN_BUTTON) login_btn.click() logger.info("点击登录按钮") def get_error_message(self) -> str: """获取错误提示文本:失败时用于断言""" try: return self.wait_for_element(*self.ERROR_MESSAGE).text except Exception: return "" # 元素不存在则返回空字符串,避免测试中断 def is_login_success(self) -> bool: """判断登录是否成功:双重校验(URL + 页面文本)""" current_url = self.driver.current_url welcome_elem = self.wait_for_element(*self.WELCOME_TEXT, timeout=5) return "/dashboard" in current_url and welcome_elem.is_displayed()逻辑说明:
- 所有定位器用元组
(By.XXX, "selector")定义,而非字符串拼接,便于 IDE 自动补全和后期统一替换(如从 ID 改为 CSS)。 wait_for_element(*self.USERNAME_INPUT)中的*解包是关键——BasePage.wait_for_element方法接收by, value两个参数,传入元组需解包。is_login_success()返回布尔值而非抛异常,是因为它会被用在while循环重试逻辑中(见tests/test_login_flow.py),符合“失败可重试”的工程实践。- 日志
logger.info("输入密码(已脱敏)")是血泪经验:曾因日志明文打印密码被安全审计打回,从此所有敏感字段日志必脱敏。
3. 数据驱动与参数化:让 1 个测试函数跑遍 23 种登录组合
3.1 从 JSON 到 pytest.param:数据加载与用例生成
data/login_data.json结构如下:
{ "valid_user": { "user": "test@demo.com", "pwd": "ValidPass123!", "expected": "success" }, "invalid_pwd": { "user": "test@demo.com", "pwd": "wrong123", "expected": "error" }, "empty_user": { "user": "", "pwd": "123456", "expected": "error" } }对应测试用例tests/test_login_flow.py:
# tests/test_login_flow.py import json import pytest from pages.login_page import LoginPage from utils.excel_reader import ExcelReader # 方式一:从 JSON 加载数据(轻量级场景) def load_login_data_from_json(): with open("data/login_data.json", "r", encoding="utf-8") as f: data = json.load(f) # 转为 pytest.param 列表:(user, pwd, expected, id) return [ pytest.param( case["user"], case["pwd"], case["expected"], id=name # 用 JSON key 作为用例 ID,报告中显示为 test_valid_user ) for name, case in data.items() ] # 方式二:从 Excel 加载(复杂场景,支持多列描述) @pytest.mark.parametrize("user,pwd,expected,desc", ExcelReader.read_login_cases("data/login_cases.xlsx")) def test_login_parametrized(driver, user, pwd, expected, desc): """参数化登录测试:覆盖有效/无效/边界场景""" login_page = LoginPage(driver) login_page.open() # 访问登录页 login_page.input_username(user) login_page.input_password(pwd) login_page.click_login() if expected == "success": assert login_page.is_login_success(), f"登录成功断言失败:{desc}" else: error_msg = login_page.get_error_message() assert error_msg, f"预期错误提示未出现:{desc}" assert "用户名或密码错误" in error_msg or "不能为空" in error_msg, f"错误提示不符:{error_msg}" # 使用 JSON 数据的测试函数 @pytest.mark.parametrize("user,pwd,expected", load_login_data_from_json()) def test_login_from_json(driver, user, pwd, expected): # 逻辑同上,复用相同断言 ...参数说明:
id=name在 pytest 报告中显示为test_login_parametrized[test_valid_user],比默认的test_login_parametrized[0]可读性强百倍。ExcelReader.read_login_cases()是自定义工具类,读取 Excel 第一行为列名(user/pwd/expected/desc),逐行转为pytest.param,支持中文描述列desc直接出现在报告中。@pytest.mark.parametrize必须放在测试函数上方,且参数名user,pwd,expected,desc必须与pytest.param传入顺序严格一致,否则报TypeError: test_login_parametrized() got multiple values for argument 'user'。
3.2 动态数据生成:用 Faker 库造真实测试数据
对于需要唯一性(如注册邮箱)或符合规则(如手机号)的场景,硬编码 JSON 不现实。框架内置utils/faker_generator.py:
# utils/faker_generator.py from faker import Faker import re fake = Faker("zh_CN") def generate_unique_email(): """生成唯一邮箱:时间戳+随机字符串,避免重复注册""" timestamp = int(time.time() * 1000) return f"test_{timestamp}_{fake.user_name()}@example.com" def validate_phone_number(phone: str) -> bool: """校验手机号格式(国内 11 位,13-19 开头)""" pattern = r"^1[3-9]\d{9}$" return bool(re.match(pattern, phone)) # 在测试中使用 def test_register_with_faker_data(driver): from utils.faker_generator import generate_unique_email email = generate_unique_email() # ... 后续注册流程注意:Faker 生成的数据需配合
pytest-randomly插件使用,否则每次运行顺序固定,无法暴露隐藏的时序 bug。本项目requirements.txt已包含pytest-randomly==3.12.0,并在pytest.ini中启用:--randomly-seed=12345(固定 seed 便于复现问题)。
4. 避坑指南:那些让框架上线前一周崩溃的 5 个真实陷阱
4.1 现象:Chrome 浏览器启动后立即关闭,控制台无报错
原因:chromedriver版本与 Chrome 浏览器主版本不匹配。例如 Chrome 124 需要 chromedriver 124.x,但项目requirements.txt错误指定chromedriver-autoinstaller==0.6.0(该版本最高支持 Chrome 122)。
解决:
- 卸载旧版:
pip uninstall chromedriver-autoinstaller - 改用
webdriver-manager(更稳定):pip install webdriver-manager==4.0.1 - 修改
conftest.py中 driver 初始化:from webdriver_manager.chrome import ChromeDriverManager from selenium.webdriver.chrome.service import Service # 替换原 driver = webdriver.Chrome(...) 为: service = Service(ChromeDriverManager().install()) driver = webdriver.Chrome(service=service, options=options)
4.2 现象:test_search_scenarios.py在本地运行通过,Jenkins 上 100% 失败
原因:Jenkins slave 节点运行在 headless 模式,但未设置--disable-gpu和--no-sandbox参数,导致 Chrome 渲染引擎崩溃。
解决:
- 在
config/browser_config.py中,headless为True时,强制追加以下参数:if self.headless: options.add_argument("--headless") options.add_argument("--no-sandbox") # 关键!绕过沙箱限制 options.add_argument("--disable-gpu") # 关键!禁用 GPU 加速 options.add_argument("--disable-dev-shm-usage") # 关键!避免 /dev/shm 空间不足
4.3 现象:test_order_submit.py执行到支付页时,wait_for_element(*self.PAYMENT_BUTTON)超时,但人工看页面按钮明明存在
原因:支付按钮被包裹在 iframe 中,driver.find_element默认在 top-level document 查找,无法定位 iframe 内元素。
解决:
- 在
pages/order_page.py中增加 iframe 切换逻辑:def switch_to_payment_iframe(self): """切换到支付 iframe""" iframe = self.wait_for_element(By.ID, "payment-iframe") self.driver.switch_to.frame(iframe) logger.info("切换到 payment iframe") def click_payment_button(self): """在 iframe 内点击支付按钮""" self.switch_to_payment_iframe() pay_btn = self.wait_for_element(*self.PAYMENT_BUTTON) pay_btn.click() self.driver.switch_to.default_content() # 切回主文档 logger.info("点击支付按钮并切回主文档")
4.4 现象:pytest --html=report.html生成的报告中,失败用例的截图路径是file:///var/jenkins_home/workspace/.../fail_20240521.png,打开后显示空白
原因:HTML 报告中的图片路径是绝对本地路径,Jenkins 服务器无法访问 slave 节点的文件系统。
解决:
- 修改
utils/screenshot_util.py,将截图保存到report/screenshots/目录,并在 HTML 报告中使用相对路径:def take_screenshot_on_failure(driver, test_name): timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") screenshot_dir = os.path.join("report", "screenshots") os.makedirs(screenshot_dir, exist_ok=True) filename = f"{test_name}_{timestamp}.png" filepath = os.path.join(screenshot_dir, filename) driver.save_screenshot(filepath) # 返回相对路径供 HTML 报告引用 return f"report/screenshots/{filename}" - 在
pytest.ini中添加:--self-contained-html参数,使报告内嵌截图 Base64 编码(终极方案,但报告体积增大)。
4.5 现象:pytest -n 3并行执行时,多个用例同时操作同一个数据库连接池,导致OperationalError: database is locked
原因:框架中utils/db_util.py的 SQLite 连接未做线程隔离,concurrent.futures.ThreadPoolExecutor下连接被共享。
解决:
- 改用
threading.local()为每个线程创建独立连接:import threading local_storage = threading.local() def get_db_connection(): if not hasattr(local_storage, 'conn'): local_storage.conn = sqlite3.connect("test.db") return local_storage.conn - 或更推荐:直接移除测试中对数据库的依赖,改用 API Mock(如
responses库)模拟后端返回,使 UI 测试真正只关注前端行为。
5. Jenkins 集成与报告可视化:让自动化结果成为团队每日站会的焦点
5.1 Jenkins Pipeline 脚本:从拉代码到发邮件的闭环
Jenkinsfile(Declarative Pipeline)内容精简但关键:
pipeline { agent any environment { PYTHONUNBUFFERED = "1" PYTHONDONTWRITEBYTECODE = "1" } stages { stage('Checkout') { steps { checkout scm } } stage('Setup Python Env') { steps { sh 'python -m venv venv' sh 'source venv/bin/activate && pip install -r requirements.txt' } } stage('Run Tests') { steps { sh 'source venv/bin/activate && pytest tests/ --html=report/report.html --self-contained-html -n 4 --maxfail=5' } } stage('Archive Reports') { steps { archiveArtifacts artifacts: 'report/**', fingerprint: true publishHTML(target: [ reportDir: 'report', reportFiles: 'report.html', reportName: 'UI Automation Report' ]) } } stage('Send Email') { steps { emailext ( subject: "UI Automation Report: ${BUILD_STATUS} - ${BUILD_NUMBER}", body: '''<p>Build: <a href="${BUILD_URL}">${BUILD_NUMBER}</a></p> <p>Report: <a href="${BUILD_URL}artifact/report/report.html">View Report</a></p> <p>Failed Tests: ${TEST_COUNTS['failed']}</p>''', to: 'qa-team@example.com', mimeType: 'text/html' ) } } } }关键点说明:
--self-contained-html确保报告内嵌截图和样式,无需额外部署静态资源。archiveArtifacts保存report/目录,publishHTML将其挂载为 Jenkins 内置链接,比手动上传 FTP 更可靠。emailext插件需提前在 Jenkins 系统配置中设置 SMTP 服务器,TEST_COUNTS['failed']由 pytest 插件pytest-html自动生成,无需额外解析 XML。
5.2 报告深度解读:不只是“Passed/Failed”,而是可归因的故障树
生成的report.html不是简单表格,而是三层钻取结构:
- Summary Tab:总用例数、通过率、失败数、耗时分布饼图;
- Tests Tab:每条用例状态(✅/❌/⚠️),点击失败用例展开:
- 控制台输出(含
logger.info日志) - 自动截图(内嵌 Base64)
- 失败堆栈(定位到
pages/login_page.py第 42 行assert login_page.is_login_success())
- 控制台输出(含
- Environment Tab:自动采集的环境信息:
Item Value OS Linux (Ubuntu 22.04) Python 3.10.12 Selenium 4.15.0 Chrome 124.0.6367.202 pytest 7.4.3
提示:若发现某类失败(如
ElementNotInteractableException)集中出现在 Chrome 124,可立即锁定为浏览器升级引发的兼容性问题,而非测试脚本缺陷——这是报告赋予的归因能力。
5.3 失败根因分析技巧:用--tb=short+--log-cli-level=INFO快速定位
当某次构建失败时,不要先看 HTML 报告,而是直接在 Jenkins 控制台输出中执行:
# 进入 workspace,激活虚拟环境 source venv/bin/activate # 用最小集复现失败用例(比跑全量快 10 倍) pytest tests/test_login_flow.py::test_valid_login -v --tb=short --log-cli-level=INFO输出示例:
test_login_flow.py::test_valid_login FAILED [100%] --------------------------------- live log call --------------------------------- INFO pages.login_page:login_page.py:32 输入用户名: test@demo.com INFO pages.login_page:login_page.py:41 输入密码(已脱敏) INFO pages.login_page:login_page.py:48 点击登录按钮 INFO utils.screenshot_util:screenshot_util.py:23 截图保存至: report/screenshots/test_valid_login_20240521_142305.png =================================== FAILURES =================================== ___________________________ test_valid_login _________________________________ ... E AssertionError: 登录成功断言失败:标准用户登录技巧:--log-cli-level=INFO将日志直接输出到控制台,结合--tb=short(精简堆栈),3 秒内定位到login_page.py第 48 行点击后未跳转,再查截图确认按钮是否被遮挡——比翻 HTML 报告快得多。
6. 进阶技巧:用pytest-bdd实现业务需求到测试用例的双向追溯
6.1 BDD 场景编写:把产品经理的 PRD 变成可执行的.feature文件
在features/目录下新建login.feature:
Feature: 用户登录功能 作为已注册用户 我希望输入正确账号密码后成功登录 以便访问个人中心 Scenario: 正确凭据登录 Given 我在登录页面 When 我输入用户名 "test@demo.com" 和密码 "ValidPass123!" And 我点击登录按钮 Then 页面应跳转到仪表盘 And 页面显示欢迎语 "欢迎回来,Test User" Scenario Outline: 异常登录场景 Given 我在登录页面 When 我输入用户名 "<user>" 和密码 "<pwd>" And 我点击登录按钮 Then 页面应显示错误提示 "<error>" Examples: | user | pwd | error | | "" | "123456" | 用户名不能为空 | | "test@demo.com"| "wrong123" | 用户名或密码错误 |6.2 Step Definition 绑定:用@given/@when/@then关联到 PageObject
features/steps/login_steps.py:
from pytest_bdd import given, when, then, scenarios from pages.login_page import LoginPage scenarios("features/login.feature") @given("我在登录页面") def navigate_to_login_page(driver): login_page = LoginPage(driver) login_page.open() # 调用 PageObject 方法 @when("我输入用户名 '<user>' 和密码 '<pwd>'") def input_credentials(login_page: LoginPage, user, pwd): login_page.input_username(user) login_page.input_password(pwd) @when("我点击登录按钮") def click_login_button(login_page: LoginPage): login_page.click_login() @then("页面应跳转到仪表盘") def verify_dashboard_url(driver): assert "/dashboard" in driver.current_url @then("页面显示欢迎语 '<welcome>'") def verify_welcome_text(login_page: LoginPage, welcome): assert welcome in login_page.get_welcome_text() # 需在 LoginPage 新增此方法优势:
- 产品经理可直接阅读
.feature文件确认需求覆盖度; - 开发修改登录逻辑时,
login.feature作为验收标准,必须同步更新; pytest-bdd自动生成测试报告,标注每个Scenario对应的代码行,实现需求→代码→报告的全链路追溯。
6.3 需求覆盖率统计:用pytest-bdd插件生成 Coverage Report
安装插件:pip install pytest-bdd[coverage]
运行命令:
pytest --bdd-features=features/ --bdd-step-definitions=features/steps/ --bdd-coverage-report=html生成bdd_coverage/目录,其中index.html显示:
- Total Scenarios: 12
- Implemented: 12 (100%)
- Missing Steps: 0
- Step Definitions Coverage: 92.3%(提示某
@then步骤未绑定)
从那以后我每次接到新需求,第一件事不是写代码,而是和产品一起把
Scenario Outline的Examples表格填满——边界值、异常流、成功流各占 1/3,再交给开发实现。这样做的好处是:测试用例不再是我一个人的脑补,而是三方(产品/开发/测试)共同签字的需求契约。框架的价值,从来不是跑得快,而是让所有人说同一种语言。希望帮到你。
本文还有配套的精品资源,点击获取