UI自动化测试框架搭建:Selenium+Python+Pytest从零到一实践指南

发布时间:2026/10/20 17:39:00
UI自动化测试框架搭建:Selenium+Python+Pytest从零到一实践指南 很多做测试和开发的朋友早晚都会遇到同一个问题手动回归测试做到想吐。一个功能改完要把主流程从头点一遍上午改代码下午点页面精力全耗在重复劳动上。越来越多项目开始尝试用UI自动化来解决这类问题但真到自己动手搭一套框架时又会发现资料很多、五花八门真正能落地的却不多。这篇教程就是围绕“UI自动化搭建”来写的我会从最常用的Selenium加Python加Pytest组合出发把一个能跑、能出报告、能稳定维护的框架从零到一搭起来也把选型理由、等待机制、PO模式这些关键点讲明白。适合手里有Web项目想提效、刚接触自动化又不想绕弯路的同学参考。1. 技术选型与框架整体设计为什么是Selenium Python Pytest1.1 工具选型的取舍逻辑先说说我为什么最终选了这套组合。市面上的选择其实挺多有Java加Selenium加TestNG、有Python加Selenium加Pytest、也有这两年很火的Playwright和Cypress。我自己在实际带项目时很多团队连基础脚本都还没跑通第一步的需求并不是“最新最酷”而是“资料全、上手快、坑有前人填过”。Selenium是Web自动化里生态最老、踩坑记录最多的一代工具覆盖率也足够像某后台管理系统、某跨平台系统的核心流程它都能稳定驱动真实浏览器执行。Python作为绑定语言语法没有Java那么繁琐写测试脚本时尤其省力。Pytest对比同类测试框架优势又很直接fixture机制管理资源非常顺手参数化做数据驱动是原生支持断言直接用assert就行插件体系也成熟。再配合pytest-html或Allure做报告整个过程零额外开发成本。性能上有人说Python慢但UI自动化的瓶颈根本不在语言而在浏览器渲染和网络等待选Python完全撑得住。1.2 项目目录结构与PO模式的价值框架设计上我强烈建议按照Page Object模式来组织项目。PO模式的核心思想是把页面上的元素定位和操作细节封装到独立的页面类里测试用例里只描述业务动作不出现find_element这种底层方法。这样做最大的好处是当页面结构变动时只需修改对应页面类中的一个定位器而不用翻遍所有用例去改几十个碎片化的locator。我用过一段时间“脚本即用例”的写法当时所有测试代码直接Selenium API平铺直叙最初几天跑得欢但等到页面第二次改版时光改定位就改了半个多小时后来再也不敢省这一步。目前我习惯的目录结构是这样的ui_automation_demo/ ├── config/ # 配置文件 │ └── settings.yaml ├── pages/ # PO页面对象 │ ├── base_page.py │ ├── login_page.py │ └── home_page.py ├── test_cases/ # 测试用例 │ ├── conftest.py │ ├── test_login.py │ └── test_home.py ├── utils/ # 公共方法 │ ├── driver_factory.py │ └── screenshot.py ├── reports/ # 测试报告与截图 └── requirements.txt这套结构的逻辑很清晰config管环境相关的数据pages管页面元素和操作test_cases管业务场景和数据断言utils提供驱动和辅助函数。从一个“能跑”的脚本升级成一个“能长期维护”的框架这一步的架构投入是必须的。2. 环境搭建与第一个可运行脚本2.1 依赖安装与浏览器驱动版本匹配环境搭建本身不难但有一个细节几乎所有新手都踩过浏览器驱动版本和浏览器版本对不上。Chrome浏览器一升级ChromeDriver不跟着升启动时就直接报session not created异常。我的习惯是关闭浏览器的自动更新或者固定使用某个版本让驱动版本始终精确匹配。Python环境建议用虚拟环境避免把依赖装乱。创建好venv后直接安装依赖pip install selenium pytest pytest-html pyyaml装完用pip show selenium确认一下版本。Selenium从4.x开始一个比较好的点是它内部集成了Selenium Manager可以自动下载驱动第一次启动时会自动拉取匹配的浏览器驱动省去了不少手动配置时间。不过在一些内网环境或驱动版本特殊的情况下还是建议手动下载并配置到环境变量里或者放在项目目录下用Service指定路径这样的可预测性更高。验证环境是否通的关键一步是先把浏览器启动起来而不是急着写用例。你可以用一段最简单的代码测一下from selenium import webdriver driver webdriver.Chrome() driver.get(https://example.com) print(driver.title) driver.quit()这种“最小验证”的习惯很好用。环境问题在一开始暴露比写到一半再排查要省力得多。页面标题能打印出来说明驱动、浏览器、网络链路全部正常后面的工作才可以继续。2.2 第一个自动化脚本启动浏览器、完成一次登录环境通了之后我们直接写一个最原始、但完整体现自动化流程的脚本。假设要自动化的是一个后台登录场景目标是打开登录页、输入用户名和密码、点击登录按钮、验证登录成功后的提示信息。import time from selenium import webdriver from selenium.webdriver.common.by import By driver webdriver.Chrome() driver.maximize_window() driver.get(https://admin.demo.local/login) driver.find_element(By.ID, username).send_keys(admin) driver.find_element(By.ID, password).send_keys(123456) driver.find_element(By.ID, loginBtn).click() time.sleep(3) welcome_text driver.find_element(By.ID, welcomeMsg).text assert 欢迎回来 in welcome_text driver.quit()这段脚本能跑通但离工程化还有距离。其中最明显的问题是time.sleep(3)代码里用固定等待非常脆弱——网络快一点的时候白白等三秒慢一点的时候三秒根本不够。别急这个点在后面“显式等待”一节会重点讲。现在先把整个链路跑通让自己有体感再一步步优化。脚本跑通之后把浏览器驱动、依赖包、目标系统地址这些内容整理好写进README。团队协作时这句话往往能让新同事少浪费一整天“依赖装好运行某路径下的脚本看到打印出欢迎信息就算环境OK。”3. 核心机制拆解定位、等待与断言3.1 元素定位策略优先选什么、避开什么元素定位是UI自动化里出问题最多的环节。定位方式有id、name、class name、tag name、link text、xpath、css selector这几种我的选择优先级很明确优先id其次data-testid这类专为自动化预留的属性然后是用css selector最后才用xpath。id简单直接、通常唯一页面结构一变只要id不变就能继续用。很多现代前端框架会在组件上内置data-testid或者data-cy属性这是为了自动化测试专门设计的钩子比xpath稳得多。xpath虽然功能最全但写绝对路径的xpath比如/html/body/div[1]/div[2]/form/div[3]/input页面多一个DOM层级就废掉了维护成本极高。如果必须用xpath也尽量写成相对定位并且结合元素的稳定特征比如# 不推荐依赖绝对DOM层级 driver.find_element(By.XPATH, /html/body/div[2]/form/input[1]) # 适合结合文本和属性语义清晰 driver.find_element(By.XPATH, //button[contains(class, submit) and text()登录])还有一个容易忽略的问题元素定位写错不会在定位那一刻马上报错而是在找不到时抛异常。所以高手会在封装层统一处理“定位不到”这件事而不是让异常散落在用例中间。后面PO模式里的BasePage就是干这个的。3.2 显式等待与隐式等待的配合逻辑现代Web应用大量使用Ajax和异步渲染页面元素出现的时机极不稳定。直接使用sleep定时等待是对执行时间最大的浪费而只用隐式等待很多复杂异步场景又等不到正确时机。所以我的做法是“全局设置隐式等待做兜底关键路径用显式等待做精确控制”。隐式等待的作用是在find_element找不到元素时轮询一段时间再抛出异常全局统一生效。设置方式很简单driver.implicitly_wait(10)但隐式等待解决不了“元素已出现但还不可点击”“元素正在加载中”这类更细腻的状态。这时就得靠显式等待加expected_conditions也就是大家常说的WebDriverWait。from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC wait WebDriverWait(driver, 10, poll_frequency0.5) submit_btn wait.until(EC.element_to_be_clickable((By.ID, submitBtn)))这段代码的实际意思很直白最多等10秒每0.5秒检查一次直到按钮可点击才继续。对比sleep的固定等待显式等待让脚本执行时间自适应页面实际加载速度既不会傻等也不会误判。那种“代码是对的但偶尔失败”的情况百分之八十都是等待时机没处理好。配合等待时我还会区分两类条件element_to_be_clickable和visibility_of_element_located。click前用前者输入文本前用后者读到一段异步加载的文本内容时再用text_to_be_present_in_element。不同动作对应不同状态等到正确的状态脚本稳定性就上一个台阶。3.3 断言怎么写才有意义断言是UI自动化最容易“形式主义”的部分。很多人跑完脚本全靠print确认结果这其实不是真正的自动化——自动化必须由程序判断用例通过还是失败。我当时踩过的一个典型误区是登录成功后只断言“登录按钮不见了”结果页面明明弹了个错误弹窗挡住了按钮脚本依然判定成功。现在我的断言标准是必须验证业务结果而不是仅验证某个中间动作。比如一个登录用例合理的断言至少包含几层思路关键元素是否出现、页面URL是否跳转、某个代表登录状态的文案或数据是否呈现。多写一条断言看起来多花几秒但能挡住大量“假通过”。Pytest里用原生assert就能完成这件事例如def test_login_success(): ... assert wait.until(EC.text_to_be_present_in_element((By.ID, userName), admin))其他常见的断言场景还有表单校验报错、列表数据条数和排序结果。总的来说断言的最终价值是让测试结果可信宁可稍微冗余也不能让它形同虚设。4. PO模式重构从脚本到可维护框架4.1 页面类封装的实际重构过程在脚本跑通、断言思路清晰之后下一步就是重构为PO模式。重构的意义不是让代码变好看而是降低后续每一次改动的成本。我用之前那个登录脚本做一遍展示具体的落地过程。首先写一个BasePage基类把find、click、input这些最常用动作统一封装并且内置显式等待from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC class BasePage: def __init__(self, driver): self.driver driver self.wait WebDriverWait(driver, 10, poll_frequency0.5) def find_element(self, locator): return self.wait.until(EC.visibility_of_element_located(locator)) def find_clickable(self, locator): return self.wait.until(EC.element_to_be_clickable(locator)) def click(self, locator): self.find_clickable(locator).click() def input_text(self, locator, text): element self.find_element(locator) element.clear() element.send_keys(text)注意input_text里的clear操作很多真实表单输入框会默认带一段提示文本或上一次输入残留不清理直接send_keys轻则文本拼接出错重则触发输入校验。这种底层细节写在基类里所有页面都能自动继承。然后写LoginPage继承BasePage把登录页的元素和操作封装起来from selenium.webdriver.common.by import By from pages.base_page import BasePage class LoginPage(BasePage): username_input (By.ID, username) password_input (By.ID, password) login_button (By.ID, loginBtn) welcome_msg (By.ID, welcomeMsg) 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_welcome_text(self): return self.find_element(self.welcome_msg).text这样写的好处看一个具体场景就清楚了有一天开发把用户名的id从username改成了userName在脚本式的写法里所有用例里凡是写过username的代码全得改在PO模式里只需要修改LoginPage顶部这一个元组。几十条用例可能只改一行代码维护成本完全不在一个量级。4.2 用fixture管理浏览器生命周期用例变成PO模式之后下一步要解决浏览器实例的管理问题。Pytest的fixture机制可以很干净地处理“每个用例前启动浏览器、用例结束后关闭浏览器”这件事。我把fixture放在test_cases目录下的conftest.py里它会被同目录及子目录的用例自动发现。直接看实现import pytest from selenium import webdriver from pages.login_page import LoginPage pytest.fixture(scopeclass) def login_page(): driver webdriver.Chrome() driver.maximize_window() driver.get(https://admin.demo.local/login) page LoginPage(driver) yield page driver.quit()这段代码里scope“class”的意思是在一个测试类中复用同一个浏览器实例。如果你更希望每个用例都独立开一个新的浏览器设置scopefunction就行。两种方式各有取舍function级别隔离更彻底但执行时间明显变长class级别速度快很多不过用例之间可能残留状态。我自己的经验是业务用例尽量用function级别保证干净界面冒烟用例用class级别提升效率。用例中直接接收fixture参数代码非常清爽def test_login_success(login_page): login_page.login(admin, 123456) assert 欢迎回来 in login_page.get_welcome_text()到这里一个基础的框架雏形已经出现了页面对象负责操作细节fixture负责生命周期用例只描述业务意图。代码量没少多少但可读性和可维护性已经完全不同。5. 数据驱动一份用例跑多组数据5.1 参数化的思路与实现UI自动化做到一定量级后会遇到大量“流程一模一样只是输入数据不同”的场景。比如登录用例要测账号正确密码错误、账号不存在、密码为空、账号锁定等一堆情况如果为每种情况复制一个函数用例代码会膨胀得很快。比较好的方式是把数据从用例代码里剥离出来用数据驱动的方式让一份逻辑跑多组数据。Pytest的parametrize就是专门干这个的import pytest pytest.mark.parametrize(username,password,expected_msg, [ (admin, wrong_password, 用户名或密码错误), (nonexist_user, 123456, 用户不存在), (, , 请输入账号), ]) def test_login_invalid(login_page, username, password, expected_msg): login_page.login(username, password) assert expected_msg in login_page.get_error_tip()这个写法的价值不止是省行数更重要的是新增一组测试数据时完全不用改用例逻辑只是往列表里加一行。数据量和逻辑复杂度彻底解耦这是自动化用例发展到一定规模后必须做的一件事。5.2 用例组织与报告产出数据驱动之后用例报告的质量也得跟上。我最常用的组合是pytest-html加allure-pytest。pytest-html配置简单适合小团队快速看结果Allure的用例历史记录、失败截图关联、步骤日志分组能力强适合用例量较多、需要长期追踪稳定性的团队。先看pytest-html的快速用法在项目根目录执行pytest test_cases --htmlreports/report.html --self-contained-html--self-contained-html参数会把所有样式、脚本打包进一个HTML文件发给别人时不会出现样式丢失的问题。从实用性角度这个输出已经可以满足大部分日常回归汇报了。如果项目需要更完整的报告形态我会加装allure-pytestpip install allure-pytest运行用例时生成结果目录再通过Allure命令行渲染成精美的报告站点pytest test_cases --alluredirreports/allure_results allure serve reports/allure_results报告的最终目的是让每个人都能快速看出“失败在哪一步、当时的页面是什么”。所以和报告配套的还需要把关键状态快照下来这部分下一章单独展开。6. 稳定性提升从能跑到跑得稳6.1 异步加载场景的等待策略组合UI自动化做得越深入越会意识到“能跑”和“跑得稳”是两回事。真实项目的页面有大量表格、弹窗、下拉框都是由JavaScript异步渲染出来的。同一个列表接口返回后表格要经过重新渲染、样式重置、滚动加载多个步骤如果固定等待不到位元素定位就忽好忽坏。我常用的等待策略组合可以总结为三条全局设置一个合理的隐式等待作为兜底通常5到10秒。页面对象的关键动作统一走BasePage里的显式等待不直接使用find_element。遇到数据加载或重新渲染的场景用wait.until等待目标条件达成优先等待业务数据出现例如表格中某行文本、某个加载动画消失。例如等待一个数据表格加载完成的写法是from selenium.webdriver.support import expected_conditions as EC wait.until(EC.visibility_of_element_located((By.XPATH, //table/tbody/tr/td[contains(text(), 目标订单号)])))等待加载动画消失的写法是wait.until(EC.invisibility_of_element_located((By.CLASS_NAME, loading-mask)))把若干个这种细颗粒度等待组合起来即使页面加载速度波动明显脚本也能跑得很稳定。这里我再强调一次尽量避免sleep。sleep一旦写进代码执行时间就完全不可控而且偶发问题也很难排查——你根本无法判断是页面真的慢还是sleep写短了。6.2 失败截图与关键信息落盘自动化用例不可能是100%稳定的跑挂了之后最重要的不是“报一个失败”而是留下足够的信息让开发者快速定位。我的一次实际经历很能说明问题某次任务执行失败报告里只有“定位元素失败”这一行错误开发者完全不知道页面当时是什么状态只能手动复现来回折腾了半个多小时。后来我养成了一个习惯在用例失败时自动截图并保存整个页面的HTML快照再附上当前浏览器URL排障时间立刻缩短了一半以上。结合Pytest注入失败截图的最简单方式是在conftest.py里监听用例结束时的结果如果失败则触发截图import pytest from datetime import datetime pytest.hookimpl(tryfirstTrue, hookwrapperTrue) def pytest_runtest_makereport(item, call): outcome yield report outcome.get_result() if report.when call and report.failed: page item.funcargs.get(login_page) or item.funcargs.get(driver) if page: timestamp datetime.now().strftime(%Y%m%d_%H%M%S) screenshot_name freports/screenshots/{item.name}_{timestamp}.png page.driver.save_screenshot(screenshot_name) html_name freports/screenshots/{item.name}_{timestamp}.html with open(html_name, w, encodingutf-8) as f: f.write(page.driver.page_source) report.user_properties.append((screenshot, screenshot_name))截图保存路径最好和项目运行目录相对固定避免跑一次换一个位置否则后续想按用例名批量查找历史截图时会很麻烦。HTML快照的价值在于哪怕页面被跳转走了快照仍然保留了出问题时那一刻的DOM结构能从代码层面直接定位到出错的元素。6.3 特殊场景处理iframe、多窗口、文件上传业务系统总免不了遇到一些特殊场景比如内嵌报表的iframe、点击后弹出新窗口的审批、上传附件的弹窗。这三个场景任何新手都会卡一阵子我直接给出处理框架。iframe要先切进去再操作元素操作完记得切回来frame wait.until(EC.frame_to_be_available_and_switch_to_it((By.ID, reportIframe))) driver.switch_to.frame(frame) # 此时才能定位iframe内部的元素 driver.find_element(By.ID, exportBtn).click() # 操作完成切回主文档 driver.switch_to.default_content()多窗口要用“句柄”来切换main_window driver.current_window_handle driver.find_element(By.ID, approveLink).click() wait.until(EC.number_of_windows_to_be(2)) for handle in driver.window_handles: if handle ! main_window: driver.switch_to.window(handle) break # 新窗口操作完成后再切回主窗口 driver.switch_to.window(main_window)文件上传最快的方式是用send_keys直接发送本地文件路径这个方法在很多场景下可以绕过系统的弹窗driver.find_element(By.ID, uploadInput).send_keys(/path/to/file.xlsx)这三个场景的共同点都是“上下文”变了。iframe和窗口切换本质上是让WebDriver明白当前操作的对象在哪个文档或窗口里只要记住“用完切回来”的原则基本不会出大问题。7. 常见问题排查与避坑记录7.1 元素找不到的排查思路在任何一个UI自动化项目里元素找不到绝对是最常见的报错。很多同学一到这一步就慌了其实排查路径很固定我整理过一张顺序排查表照着走基本都能解决。常见现象可能原因排查方向NoSuchElementException定位器写错或页面结构变了先在浏览器DevTools中用对应selector确认元素存在元素存在但定位失败元素在iframe或shadow DOM内检查页面DOM树确认上下文切换定位器正确但偶发失败异步加载未完成改用显式等待等待元素可操作状态元素刚出现但不可点击被弹窗或遮罩层覆盖检查是否有loading蒙层等待其消失浏览器升级后全部脚本失败驱动版本与浏览器不匹配对照版本表重新下载匹配的ChromeDriver排查方法的顺序是从“元素是否存在”开始再到“上下文是否正确”再到“时机是否成熟”最后才去看是否有覆盖物遮挡。这个顺序能帮你把最玄学的问题变成线性排查很大程度上消除“它刚才明明还能跑”的情况。遇到偶发失败时一定记得配合失败截图和页面HTML快照来分析而不是凭记忆猜。7.2 网络延迟与资源加载导致的偶发失败即使在稳定的内网环境脚本也会偶发失败原因往往出在资源加载和网络延迟。有些页面本身依赖第三方CDN资源加载慢时页面核心功能未初始化完此时去定位元素即使使用了元素等待也可能命中“元素已出现在DOM中但事件绑定尚未完成”的状态。点击下去没有反应误报也就随之而来了。处理这类问题我的习惯是加一层“业务条件等待”不只是等元素可见而是等与业务相关的结果状态出现。比如一个搜索页面点击搜索按钮后可能有一个“搜索结果加载完成”的提示一个报表页面可能有“已生成时间”的标志。等待这些业务条件达成比等待某个元素可见要可靠得多。在框架里可以这样写from selenium.webdriver.support import expected_conditions as EC wait.until(EC.text_to_be_present_in_element((By.CLASS_NAME, search-status), 查询完成))如果业务条件本身不稳定再考虑对偶现失败做一次自动重试。重试不是逃避问题而是给环境波动留出恢复窗口。但注意脚本本身逻辑错误的重试意义不大重试次数控制在一到两次就好还得在日志里留下明显标记。7.3 一些让日常维护轻松很多的小习惯做到这一步框架已经能稳定运行了。但根据我自己的长期观察框架搭起来只是开始能长期维护下去才是价值所在。这里有三个很小的习惯非常想分享给正在做UI自动化的朋友。第一个习惯是元素定位的id和class尽量以页面功能语义来命名。虽然这话更像给前端同事的建议但作为自动化测试方我会主动与前端约定可测试的元素尽量加上data-testid并且名称保持稳定。这样可以彻底摆脱“页面一改样式自动化脚本就崩”的困局。第二个习惯是每一条用例都模拟真实用户的关键操作路径而不是专注于点击每一个角落。很多新手喜欢把用例写得很长恨不得把整个页面所有操作都放在一条用例里一旦中间出错后面全部跳过排查成本极高。我的原则是把业务路径按场景拆开一条用例只负责一个清晰的目标失败了定位问题只需要看对应场景效率完全不一样。第三个习惯是把测试数据和测试环境配置外置到config/settings.yaml脚本里不写死环境地址和数据。不同的部署环境某测试环境、某预发环境通过读取配置切换执行目标一套用例就可以复用在多个环境里。不同环境跑同样的用例一旦相互环境配置有差异还能提前暴露部署问题这是测试脚本之外额外的收益。分享一个我最常用到的实际经验当UI自动化的执行结果里出现大面积失败时不要急着怀疑代码先去看环境是否正常、数据库是否有脏数据。很多“今天跑挂、明天跑过”的用例根因都在测试数据上。为每条用例准备独立的、可重复使用的测试数据让用例互不影响这一步做好稳定性往往立刻回升。框架搭建这件事越到后期越会体会到真正复杂的从来不是Selenium的API而是对业务的理解和工程化的组织。把这些基础的等待、封装、数据驱动和排查手段梳理清楚后续无论切换到什么新工具思路都能很快迁移过去。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询