最近刚做完一个后台管理系统的UI自动化测试项目,技术选型敲定的是Pytest+Selenium这套组合。一方面是团队已有Python基础,另一方面是Pytest的fixture机制、数据驱动、插件生态确实比unittest舒服太多,Selenium又是Web UI自动化的老牌标杆,稳定、资料多、社区踩坑记录全。这篇就围绕“Pytest+selenium UI自动化测试”的完整实战过程来聊,从环境搭建、框架设计到用例编写、报告输出和稳定性的各种坑,希望能给正在上UI自动化的朋友一份可以直接参考的实操手册。
我一直觉得UI自动化测试最怕的不是写代码,而是“写完了跑不稳”。定位不稳定、等待时间不准、数据依赖太强,都会让这套自动化沦为天天报红、最后没人维护的摆设。所以这篇文章不会只讲pytest怎么用、selenium怎么定位,还会重点说说我在真实项目里遇到的稳定性问题和排查思路,以及怎么用fixture和page object把这些乱象收拾干净。如果你正打算在公司落地UI自动化,或者已经写了几个用例但跑起来翻车不断,这篇应该能帮上忙。
1. 方案选型与项目前期规划
1.1 为什么选Pytest和Selenium
开始之前先说说选型。团队里有人提过Robot Framework,也有人想用Playwright或Cypress,但我最后还是定了Pytest+Selenium。原因是这套组合的灵活性和可控性最高,尤其是当被测系统本身技术栈比较复杂、页面交互很多的时候,Selenium的生态最成熟,遇到任何问题几乎都能搜到解决方案。
Pytest的优势主要体现在几个方面:fixture替代了传统的setup/teardown,作用域清晰,数据共享方便;parametrize做数据驱动非常轻量;插件体系丰富,失败重跑、用例排序、并行执行、Allure报告都能通过插件快速集成。unittest这些虽然也能用,但代码冗余度比较高,封装的灵活性也差一些。Robot Framework更适合关键字驱动、团队里不懂编程的人多的情况,但如果后续要做复杂断言、要跟企业内部的接口平台串联,反而会受框架限制。
Selenium选择WebDriver版本时注意,Selenium 4已经内置了相对定位器、更好的窗口管理等能力,而且配合webdriver-manager可以自动处理浏览器驱动版本,省去了手动下载chromedriver的麻烦。如果你的项目还停留在Selenium 2时代的老写法,建议直接升级到4.x,API兼容性做得还不错。
1.2 需求拆解与成本评估
很多刚接触UI自动化的同学容易犯一个错误:拿到一个系统就想着把页面上所有功能全部自动化,结果脚本量巨大、维护成本爆炸。我在这个项目里先做了范围拆解,把适合UI自动化的用例筛出来,不适合的坚决不碰。
适合做的功能通常具备这些特征:主流程频繁回归、涉及多页面跳转和交互、纯界面操作难以用接口模拟。典型就是登录、创建订单、审批流程、列表查询加详情校验。不适合UI自动化的场景包括:底层数据校验、大量并发、逻辑复杂但页面反馈单一的内容,这类建议用接口测试或单元测试去覆盖。
我当时把需求拆成三层:最底层是“冒烟用例”,覆盖登录、首页加载、核心菜单跳转,每个版本都跑;中间层是“主流程用例”,比如新增用户、创建订单、提交审批;最上层是“扩展场景”,包括异常输入、权限校验、分页和筛选组合。每层的数量控制好,UI自动化用例数量一般不建议超过接口用例的一半,否则维护成本会非常重。
成本评估上还要算一笔账:每条用例从编写到稳定运行,平均需要3到5天,这还不包括定位策略调整和异常场景数据准备。如果一次性铺300条用例,1000个工时打底。所以前期不要追求数量,先把主流程跑通,再逐步扩充。
1.3 环境准备与依赖安装
环境这块我用的是Python 3.9,Selenium 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(service=Service(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放用例和fixture;common放公共工具。报告和日志各自归档,方便排查。
这样做的核心思路就是把“页面操作”和“测试断言”分离。页面对象仅负责元素的定位和操作,用例层只写业务逻辑和数据断言,页面改动时只需要维护对应的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(scope="function") def driver(): service = Service(ChromeDriverManager().install()) driver = webdriver.Chrome(service=service) 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, timeout=10) 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(f"reports/{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文件,读取后直接传给parametrize:
import yaml def load_login_data(): with open("data/login_data.yaml", encoding="utf-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 --html=reports/report.html --self-contained-html--self-contained-html会把css、js都打进一个文件,方便直接分享给别人打开。但pytest-html的缺点也很明显,不能按用例分组展示,也没有趋势图。如果想更有高级感的报告,还是推荐Allure。
Allure的使用需要两步。先安装allure-pytest插件,再安装Allure命令行工具。执行完用例后生成临时结果:
pytest --alluredir=reports/allure-results然后再生成Web报告:
allure generate reports/allure-results -o reports/allure-report --clean allure open reports/allure-reportAllure报告里能看到每个用例的步骤、参数、截图、失败日志,尤其是对于UI自动化,失败时自动截图是非常实用的功能。我在conftest里加了一个pytest的hook,专门在失败用例执行完后截图:
@pytest.hookimpl(tryfirst=True, hookwrapper=True) 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(f"reports/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_attempts=3): 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(amount=100) 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: 'git@gitlab.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 --html=reports/report.html --alluredir=reports/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自动化是用来辅助测试的,不是用来替代手工的。它最擅长的是重复回归和基础功能验证,真正需要探索性测试、视觉评测的环节,人还是更重要。想清楚这个定位,你写代码的时候就不会过度设计,落地节奏也会更顺。