Pytest+Selenium UI自动化测试实战:从框架搭建到稳定性优化 最近刚做完一个后台管理系统的UI自动化测试项目技术选型敲定的是PytestSelenium这套组合。一方面是团队已有Python基础另一方面是Pytest的fixture机制、数据驱动、插件生态确实比unittest舒服太多Selenium又是Web UI自动化的老牌标杆稳定、资料多、社区踩坑记录全。这篇就围绕“Pytestselenium UI自动化测试”的完整实战过程来聊从环境搭建、框架设计到用例编写、报告输出和稳定性的各种坑希望能给正在上UI自动化的朋友一份可以直接参考的实操手册。我一直觉得UI自动化测试最怕的不是写代码而是“写完了跑不稳”。定位不稳定、等待时间不准、数据依赖太强都会让这套自动化沦为天天报红、最后没人维护的摆设。所以这篇文章不会只讲pytest怎么用、selenium怎么定位还会重点说说我在真实项目里遇到的稳定性问题和排查思路以及怎么用fixture和page object把这些乱象收拾干净。如果你正打算在公司落地UI自动化或者已经写了几个用例但跑起来翻车不断这篇应该能帮上忙。1. 方案选型与项目前期规划1.1 为什么选Pytest和Selenium开始之前先说说选型。团队里有人提过Robot Framework也有人想用Playwright或Cypress但我最后还是定了PytestSelenium。原因是这套组合的灵活性和可控性最高尤其是当被测系统本身技术栈比较复杂、页面交互很多的时候Selenium的生态最成熟遇到任何问题几乎都能搜到解决方案。Pytest的优势主要体现在几个方面fixture替代了传统的setup/teardown作用域清晰数据共享方便parametrize做数据驱动非常轻量插件体系丰富失败重跑、用例排序、并行执行、Allure报告都能通过插件快速集成。unittest这些虽然也能用但代码冗余度比较高封装的灵活性也差一些。Robot Framework更适合关键字驱动、团队里不懂编程的人多的情况但如果后续要做复杂断言、要跟企业内部的接口平台串联反而会受框架限制。Selenium选择WebDriver版本时注意Selenium 4已经内置了相对定位器、更好的窗口管理等能力而且配合webdriver-manager可以自动处理浏览器驱动版本省去了手动下载chromedriver的麻烦。如果你的项目还停留在Selenium 2时代的老写法建议直接升级到4.xAPI兼容性做得还不错。1.2 需求拆解与成本评估很多刚接触UI自动化的同学容易犯一个错误拿到一个系统就想着把页面上所有功能全部自动化结果脚本量巨大、维护成本爆炸。我在这个项目里先做了范围拆解把适合UI自动化的用例筛出来不适合的坚决不碰。适合做的功能通常具备这些特征主流程频繁回归、涉及多页面跳转和交互、纯界面操作难以用接口模拟。典型就是登录、创建订单、审批流程、列表查询加详情校验。不适合UI自动化的场景包括底层数据校验、大量并发、逻辑复杂但页面反馈单一的内容这类建议用接口测试或单元测试去覆盖。我当时把需求拆成三层最底层是“冒烟用例”覆盖登录、首页加载、核心菜单跳转每个版本都跑中间层是“主流程用例”比如新增用户、创建订单、提交审批最上层是“扩展场景”包括异常输入、权限校验、分页和筛选组合。每层的数量控制好UI自动化用例数量一般不建议超过接口用例的一半否则维护成本会非常重。成本评估上还要算一笔账每条用例从编写到稳定运行平均需要3到5天这还不包括定位策略调整和异常场景数据准备。如果一次性铺300条用例1000个工时打底。所以前期不要追求数量先把主流程跑通再逐步扩充。1.3 环境准备与依赖安装环境这块我用的是Python 3.9Selenium 4.15。Python版本不用刻意追求最新3.9到3.12都可以只要pytest、selenium这些核心库兼容就行。浏览器用的Chrome配合webdriver-manager自动拉取driver团队里任何人拉下代码都能直接跑不用各自去配浏览器驱动。依赖安装建议用requirements.txt统一管理避免团队环境不一致导致诡异问题。最精简的一组依赖如下pip install pytest pip install selenium pip install webdriver-manager pip install pytest-html pip install pytest-rerunfailures pip install pytest-xdist pip install allure-pytest pip install pyyaml pip install openpyxl装好后先用一个小脚本验证Selenium环境避免后面写了一大堆用例发现驱动跑不起来from selenium import webdriver from webdriver_manager.chrome import ChromeDriverManager from selenium.webdriver.chrome.service import Service driver webdriver.Chrome(serviceService(ChromeDriverManager().install())) driver.get(https://www.baidu.com) print(driver.title) driver.quit()能正常打印页面标题说明环境OK。之后我会把driver的初始化收进conftest的fixture里所有用例共用一套启动逻辑。2. 测试框架搭建与核心封装2.1 项目目录结构设计UI自动化项目最忌“一锅炖”所有脚本堆在同一个目录里。好的目录结构可以直接减少后期维护的心智负担。我这个项目的目录如下project/ ├── config/ │ ├── conf.py │ ├── data.yaml ├── data/ │ ├── login_data.yaml │ ├── order_data.xlsx ├── page_objects/ │ ├── base_page.py │ ├── login_page.py │ ├── order_page.py ├── test_cases/ │ ├── conftest.py │ ├── test_login.py │ ├── test_order.py ├── common/ │ ├── log_util.py │ ├── assert_util.py │ ├── screenshot_util.py ├── reports/ ├── logs/ ├── requirements.txt ├── pytest.ini ├── run.pyconfig统一放环境地址、账号、超时时间等配置data放测试数据page_objects放页面对象层test_cases放用例和fixturecommon放公共工具。报告和日志各自归档方便排查。这样做的核心思路就是把“页面操作”和“测试断言”分离。页面对象仅负责元素的定位和操作用例层只写业务逻辑和数据断言页面改动时只需要维护对应的page_objects用例基本不用动。这是UI自动化能长期维护的前提。2.2 Pytest的fixture管理测试生命周期fixture是Pytest最值回票价的功能它从根本上替代了传统的setUp/tearDown方法。UI自动化里最常用的fixture就是浏览器实例的启动和关闭我一般会放在test_cases/conftest.py里import pytest from selenium import webdriver from webdriver_manager.chrome import ChromeDriverManager from selenium.webdriver.chrome.service import Service from config.conf import BASE_URL pytest.fixture(scopefunction) def driver(): service Service(ChromeDriverManager().install()) driver webdriver.Chrome(serviceservice) driver.maximize_window() driver.implicitly_wait(5) driver.get(BASE_URL) yield driver driver.quit()这里的scope默认是function也就是每个用例都会重新启动一次浏览器。这样隔离性最好用例之间不会互相干扰缺点是慢。如果被测系统登录状态可以复用、用例都围绕同一模块跑可以把scope调成class甚至session但要注意登录态和页面残留数据的影响我一般建议刚开始跑就用function级别的隔离稳定后再优化速度。fixture的返回值得小心。上面yield后面返回driver用例可以直接接收driver参数。如果你在用例里还要断言页面元素建议再封装一个page层直接把driver传给页面对象构造函数这样用例代码更干净。2.3 POM页面对象模型落地POM是UI自动化项目里的标准姿势。它的核心思想是为每个页面创建一个类把页面的元素定位和操作方法封装在类里测试用例调用这些方法不直接面对find_element。BasePage是所有页面类的父类我在这里封装了一些基础能力比如等待元素出现、点击、输入、截图、滚动等from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By from common.log_util import logger class BasePage: def __init__(self, driver): self.driver driver self.wait WebDriverWait(driver, timeout10) def find_element(self, locator): try: return self.wait.until(EC.visibility_of_element_located(locator)) except Exception as e: logger.error(f定位元素失败: {locator}, 页面源码: {self.driver.page_source[:500]}) raise e def click(self, locator): element self.find_element(locator) element.click() def input_text(self, locator, text): element self.find_element(locator) element.clear() element.send_keys(text) def screenshot(self, name): self.driver.save_screenshot(freports/{name}.png)这个BasePage的好处是超时会把页面源码打出来排查问题不用再手动复制源码click和input都统一走同一个查找逻辑不会出现有的地方用了隐式等待、有的地方没等的问题。具体业务页面继承BasePage比如登录页from page_objects.base_page import BasePage from selenium.webdriver.common.by import By class LoginPage(BasePage): username_input (By.ID, username) password_input (By.ID, password) login_button (By.ID, loginBtn) login_error (By.CSS_SELECTOR, .error-tip) 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): return self.find_element(self.login_error).text用例里调用LoginPage().login(admin, 123456)整个链路非常清爽。后续页面结构改了只需要改LoginPage里面的定位符不必动测试用例。3. 用例设计与数据驱动实战3.1 登录用例的完整实现登录算是最典型的UI自动化案例了既有成功场景又有异常校验。我在test_login.py里这样实现import pytest from page_objects.login_page import LoginPage from config.conf import BASE_URL class TestLogin: def test_login_success(self, driver): login_page LoginPage(driver) login_page.login(admin, 123456) assert login_page.wait_for_redirect(home) assert 欢迎回来 in login_page.get_login_success_message() def test_login_wrong_password(self, driver): login_page LoginPage(driver) login_page.login(admin, wrong) assert 用户名或密码错误 in login_page.get_error_message()我一般不会在用例里直接写sleep而是依赖BasePage里的显式等待。这样用例跑起来不会白白等固定时间元素就绪后马上继续执行。注意断言的时候尽量用“包含”而不是“等于”因为页面文案经常会有前后空格或者动态拼接严格等于容易误报。每个用例之间建议互不依赖。如果用例A创建了一个订单用例B去查询订单那我不会让B依赖A的执行结果而是让B自己通过接口或SQL准备前置数据。UI用例跑一遍不容易数据互相依赖会让排查难度成倍上升。3.2 数据驱动让用例可复用登录场景天然适合数据驱动。不同账号、不同密码、不同预期提示如果用一条用例写死后面就是无穷无尽的复制粘贴。Pytest的parametrize是我最常用的方法import pytest from page_objects.login_page import LoginPage class TestLogin: pytest.mark.parametrize(username,password,expected, [ (admin, 123456, 登录成功), (admin, wrong, 用户名或密码错误), (, 123456, 请输入用户名), (test01, test123, 登录成功), ]) def test_login_multiple(self, driver, username, password, expected): login_page LoginPage(driver) login_page.login(username, password) assert expected in login_page.get_page_message()当数据量更大时建议从yaml或excel读数据避免把测试数据硬编码在用例代码里。我用的是yaml文件读取后直接传给parametrizeimport yaml def load_login_data(): with open(data/login_data.yaml, encodingutf-8) as f: return yaml.safe_load(f)[login_cases] pytest.mark.parametrize(case, load_login_data()) def test_login_from_yaml(self, driver, case): login_page LoginPage(driver) login_page.login(case[username], case[password]) assert case[expected] in login_page.get_page_message()这样做的好处是产品和测试同学后续要加用例直接改yaml就行不需要碰代码。需要注意的是parametrize的参数名和函数参数名必须一致否则Pytest会直接报错这个细节很容易踩。3.3 失败自动重跑与超时策略UI自动化最让人头疼的就是偶发性失败。页面偶尔加载慢几秒某个弹窗延迟出现没等到就点击了用例就红了。这种时候直接失败重跑比让测试人员人工确认快得多。我引入了pytest-rerunfailures在pytest.ini里配置[pytest] addopts -v -s --reruns 2 --reruns-delay 3--reruns 2表示失败后最多重跑2次--reruns-delay 3表示重跑前等待3秒给页面留缓冲时间-s意味着允许print输出排查有用。要注意的是重跑只能解决偶发性问题不能给所有用例无脑配置重跑次数太多。否则真有问题会拖慢整条执行链路而且掩盖了真实缺陷。我通常只允许重跑1到2次重跑后的失败仍然需要手动确认是环境问题还是产品bug。更稳妥的做法是配合标记比如冒烟用例不允许重跑避免关键路径被重试掩盖稳定用例可以重跑一次。3.4 生成直观的HTML和Allure报告自动化测试跑完的成果能不能直观展示直接影响团队是否愿意使用这套体系。我最早用的pytest-html配置非常省事pytest --htmlreports/report.html --self-contained-html--self-contained-html会把css、js都打进一个文件方便直接分享给别人打开。但pytest-html的缺点也很明显不能按用例分组展示也没有趋势图。如果想更有高级感的报告还是推荐Allure。Allure的使用需要两步。先安装allure-pytest插件再安装Allure命令行工具。执行完用例后生成临时结果pytest --alluredirreports/allure-results然后再生成Web报告allure generate reports/allure-results -o reports/allure-report --clean allure open reports/allure-reportAllure报告里能看到每个用例的步骤、参数、截图、失败日志尤其是对于UI自动化失败时自动截图是非常实用的功能。我在conftest里加了一个pytest的hook专门在失败用例执行完后截图pytest.hookimpl(tryfirstTrue, hookwrapperTrue) def pytest_runtest_makereport(item, call): outcome yield report outcome.get_result() if report.when call and report.failed: driver item.funcargs.get(driver) if driver: driver.save_screenshot(freports/fail_{item.name}.png)这样Allure报告和本地目录里都会有失败的现场截图排查问题能省一半时间。4. 定位策略、等待机制与复杂元素处理4.1 元素定位的实战经验很多UI自动化跑不稳根因都是定位写得不够稳。Selenium提供了很多定位方式我最推荐的使用顺序是id优先然后是data-testid其次才是CSS和XPath。找元素的时候不要滥用XPath的绝对路径一旦页面结构调整哪怕只是加了一个div整个定位就失效了。实际项目里表单元素还好麻烦的是表格、动态列表、弹窗这些。我经常用相对XPath定位比如“包含某文本的按钮”from selenium.webdriver.common.by import By confirm_button (By.XPATH, //button[contains(text(),确定)])在表格里操作用文本和属性结合能提高命中率。例如表格第一行“操作”栏下的“编辑”按钮edit_button (By.XPATH, //tr[1]//button[contains(class,edit)])但这里有个坑如果表格数据是从接口动态渲染的顺序可能会变。最好配合测试数据的唯一标识来定位比如订单号作为条件。Selenium 4里引入了相对定位器用起来比XPath直观能根据元素的方位去找关联元素from selenium.webdriver.common.by import By from selenium.webdriver.support.relative_locator import with_tag_name submit_button driver.find_element(with_tag_name(button).below(login_input))虽然相对定位器很方便但在复杂页面里还是XPath更稳不建议全盘依赖相对定位器。4.2 隐式等待与显式等待的正确姿势新手最常见的错误是上来就time.sleep(3)页面慢的时候照样挂页面快的时候白白浪费3秒。Selenium本身有隐式等待和显式等待机制搞清楚这两者的适用场景很关键。隐式等待是driver级别的比如driver.implicitly_wait(5)它会作用于所有元素发现过程如果在5秒内找到了元素就马上继续找不到则一直轮询到超时。这个设置写在driver初始化之后就行我通常设为5秒。问题是隐式等待管不到元素可见、可点击这些状态所以只靠它是远远不够的。显式等待是针对特定条件的更精准。Selenium内置了很多expected_conditions我日常最常用的是from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC WebDriverWait(driver, 10).until(EC.visibility_of_element_located((By.ID, orderList))) WebDriverWait(driver, 10).until(EC.element_to_be_clickable((By.ID, submit))) WebDriverWait(driver, 10).until(EC.text_to_be_present_in_element((By.ID, status), 成功))显式等待可以精确表达“这个元素可见了”“这个按钮能点了”“这段文本出现了”非常贴合业务场景。我的BasePage里就是统一用显式等待把timeout默认设成10秒遇到特殊情况再单独调整。这里专门说一句尽量不要同时用隐式等待和显式等待。两个等待机制叠加时可能出现意想不到的超时比如隐式等待5秒、显式等待10秒页面元素在3秒出现时没问题但一旦超时总时长会被两个机制叠加影响到容易混淆排查方向。4.3 iframe、Shadow DOM和多窗口切换后台管理系统里iframe特别常见尤其是在老旧的系统中。如果元素明明就在页面里find_element却一直报找不到多半是元素在iframe里。切换iframe的标准姿势是iframe driver.find_element(By.TAG_NAME, iframe) driver.switch_to.frame(iframe) # 操作iframe内部的元素 driver.switch_to.default_content()注意操作完iframe内部元素后必须切回default_content否则后续定位外面的元素会失败。嵌套iframe时还要一级一级switch不能一跳到底。每次切换前确认当前所处的上下文这是很多老手也会忽略的问题。多窗口的情况也类似点击“新窗口打开”或“下载弹窗”后需要切换window。Selenium 4里可以用window_handles获取窗口句柄或者直接用新窗口的标题定位handles driver.window_handles driver.switch_to.window(handles[-1])切换完之后用完了再切回主窗口顺序别乱。4.4 动态加载元素的稳定性处理如今的前端几乎都是SPA框架无线滚动、下拉加载、异步刷新非常普遍。这种动态加载场景用显式等待还不够还需要适当的轮询逻辑。我对列表页刷新的处理是写了一个通用方法反复点击“刷新”按钮直到元素出现def wait_for_element_with_refresh(self, locator, max_attempts3): for i in range(max_attempts): try: element self.find_element(locator) return element except Exception: self.driver.refresh() raise TimeoutError(f刷新{max_attempts}次后仍未找到元素: {locator})这个方法在处理那种初次加载慢、刷新后能显示的场景里非常实用。不过别滥用能通过正常显示等待解决的还是优先用等待刷新属于重操作频繁刷新会让用例变慢。5. 常见问题排查与稳定性优化5.1 高频报错原因与解决套路UI自动化跑久了问题基本集中在几个固定类型。我做了一个问题速查表团队排障时照着查能省很多时间。问题现象常见原因解决建议element is not clickable元素被遮住、未完全加载、位置移动换成execute_script点击先滚动到元素可见延长点击前等待no such element元素定位符错误、iframe未切换、页面未加载完成检查定位符确认是否在iframe内用显式等待替代隐式等待stale element reference页面重新渲染、旧元素被替换重新查询元素不要长时间持有旧元素引用TimeoutException等待时间不够或者页面报错没正常渲染抓取page_source看页面实际状态扩大WebDriverWait超时时间到15秒session not created浏览器驱动版本和浏览器版本不匹配用webdriver-manager自动匹配更新chrome或driver登录态中途失效session过期、cookie被清除用例前置重新登录或通过接口设置cookie遇到元素查找失败我最常用的手段是先driver.page_source打印出来人工看一眼页面到底长什么样。很多问题根本不需要猜看页面源码里有没有目标元素就清楚了。5.2 用例执行速度优化UI自动化慢是公认的但通过合理的优化能比同规模项目快30%到50%。我常做的优化第一是缩短不必要的等待不用的元素不要用长超时第二是减少登录次数能复用登录态的用例尽量复用。pytest-xdist可以多进程并行跑用例pytest -n 3-n 3表示开3个浏览器进程并行执行。并行前要考虑用例间的数据独立性如果用例之间共享了同一份测试数据并行会相互污染。我的处理办法是每个进程使用不同的账号和不同的数据前缀保证数据隔离。另一个优化点是浏览器headless模式。如果只在CI环境中跑不弹浏览器窗口能显著节省资源options.add_argument(--headless) options.add_argument(--no-sandbox) options.add_argument(--disable-dev-shm-usage)不过headless模式下的截图、滚动顺序可能跟有头模式略有差异出现问题时先切回有头模式复现一遍不要在headless里一直猜。5.3 测试数据准备与环境独立性UI自动化跑得稳不稳很大程度上取决于测试数据稳不稳。我的项目里有一条铁律所有用例使用的测试数据必须在用例开始时准备好用例结束时要清理掉绝不依赖上一条用例跑完留下的数据。具体落地时我用pytest的fixture来造数据。比如创建订单用例前置步骤是调用接口创建一条指定金额的订单数据用例结束时再把这条订单删除或标记为无效import pytest from api.order_api import create_order, delete_order pytest.fixture() def new_order(): order_id create_order(amount100) yield order_id delete_order(order_id)这样用例本身不负责数据准备只关心页面操作。即使跑错了也不会污染库里其他数据。对UI自动化来说这种接口造数UI验证的组合是最稳的成本也比纯UI操作造数低很多。环境独立性也很关键。我通过config/conf.py统一控制环境地址和账号不同环境跑测试只是改配置用例代码完全不用动。配置文件里如果有敏感信息记得加进.gitignore避免提交到代码仓库。5.4 与Jenkins的CI流水线集成UI自动化最终一定要接入CI让它定时跑、发报告、通知结论才算真正落地。我使用的Jenkins流水线大概长这样pipeline { agent any stages { stage(checkout) { steps { git url: gitgitlab.com:test_team/ui_test.git, branch: main } } stage(install dependencies) { steps { sh python3 -m venv venv sh source venv/bin/activate pip install -r requirements.txt } } stage(run pytest) { steps { sh source venv/bin/activate pytest -n 3 --htmlreports/report.html --alluredirreports/allure-results } } stage(publish report) { steps { allure includeProperties: true, jdk: default, reportBuildPolicy: ALWAYS, results: [[path: reports/allure-results]] } } } }Jenkins构建结束后Allure报告会在页面里直接展示出来配合邮件通知给全组。目前团队每天凌晨跑一次全量UI回归平时merge代码时先生成预览用例集基本不需要人工干预。6. 从实战中总结的几条稳定经验UI自动化项目做了几年最大的体会是代码能力只占一半另一半是对“稳定”二字的理解。首先元素定位要追求“让脚本更接近人怎么操作”。人点击一个按钮之前会先看到这个按钮判断它可点击脚本也要等它可点击。人看会页面上的提示文字再断言脚本也要先等到提示出现再断言。用这个标准去写每一条用例基本上不会写出大风大浪稳定性的脚本。其次脚本里尽量不要引入随机性。我之前踩过坑为了测试列表分页随机点击了一页结果用例时好时坏排查时发现随机选择的数据状态不一。后来统一改成用固定定位符或固定数据虽然用例看起来不那么“随机”但稳定性高了好几个档次。再就是重跑机制要克制。有的朋友为了让构建“绿”把重跑次数调到5次结果产品缺陷被掩盖了半个月。我建议重跑次数最多2次而且每次重跑都保留日志记录是第几次跑通过的。对于确实偶发的失败单独标记成flaky单独维护一个名单定期的稳定性分析能看出趋势。还有一点就是如果你发现某条用例频繁失败不要想着加等待时间苟过去而是去定位真正的根因。最常见的是前端改了属性名、接口返回变慢、数据结构变化这些都是信息不是“脚本问题”。我在项目里固定每周花半天看一遍当周失败用例把根因归类调整定位或数据策略后整个用例套件的稳定性会越来越好看。最后UI自动化是用来辅助测试的不是用来替代手工的。它最擅长的是重复回归和基础功能验证真正需要探索性测试、视觉评测的环节人还是更重要。想清楚这个定位你写代码的时候就不会过度设计落地节奏也会更顺。