很多做测试和开发的朋友,早晚都会遇到同一个问题:手动回归测试做到想吐。一个功能改完,要把主流程从头点一遍,上午改代码,下午点页面,精力全耗在重复劳动上。越来越多项目开始尝试用UI自动化来解决这类问题,但真到自己动手搭一套框架时,又会发现资料很多、五花八门,真正能落地的却不多。这篇教程就是围绕“UI自动化搭建”来写的,我会从最常用的Selenium加Python加Pytest组合出发,把一个能跑、能出报告、能稳定维护的框架从零到一搭起来,也把选型理由、等待机制、PO模式这些关键点讲明白。适合手里有Web项目想提效、刚接触自动化又不想绕弯路的同学参考。
1. 技术选型与框架整体设计:为什么是Selenium + Python + Pytest
1.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_frequency=0.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_frequency=0.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(scope="class") 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”的意思,是在一个测试类中复用同一个浏览器实例。如果你更希望每个用例都独立开一个新的浏览器,设置scope="function"就行。两种方式各有取舍: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 --html=reports/report.html --self-contained-html--self-contained-html参数会把所有样式、脚本打包进一个HTML文件,发给别人时不会出现样式丢失的问题。从实用性角度,这个输出已经可以满足大部分日常回归汇报了。
如果项目需要更完整的报告形态,我会加装allure-pytest:
pip install allure-pytest运行用例时生成结果目录,再通过Allure命令行渲染成精美的报告站点:
pytest test_cases --alluredir=reports/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(tryfirst=True, hookwrapper=True) 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 = f"reports/screenshots/{item.name}_{timestamp}.png" page.driver.save_screenshot(screenshot_name) html_name = f"reports/screenshots/{item.name}_{timestamp}.html" with open(html_name, "w", encoding="utf-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,而是对业务的理解和工程化的组织。把这些基础的等待、封装、数据驱动和排查手段梳理清楚,后续无论切换到什么新工具,思路都能很快迁移过去。