☰
Selenium+Python:从环境搭建到Page Object,构建Web UI自动化测试框架
2026/10/7 4:52:43 网站建设 项目流程

刚接手一个前后端分离项目的时候,我还靠手工点页面做回归:每次版本迭代都要把注册、登录、下单流程从头到尾点一遍。这种活干几次还能忍,干多了就发现,人眼会漏检、手速会变慢、半夜发版根本盯不住。所以我把目光转向了Selenium,一套用Python驱动真实浏览器做Web测试的老牌方案。Selenium能在Chrome、Firefox、Edge这些真实浏览器里模拟用户操作:点按钮、填表单、切页面、读断言,和真实用户的使用路径几乎完全一致。这篇文章适合刚接触自动化测试、准备用Python搭一套Web UI自动化框架的测试工程师,也适合前端和全栈开发想给自己写几个冒烟回归脚本的读者。后面我讲的所有内容都以实际项目为背景,从环境搭建讲到Page Object模式,把踩过的坑和取舍一起说清楚。

1. Selenium解决的是哪种测试问题,割舍掉哪些场景

1.1 UI回归测试为什么值得做自动化

在测试金字塔里,Selenium对应的是最顶端的UI测试。接口测试能校验数据对不对,单元测试能校验函数逻辑对不对,但只有UI测试能回答一个最要命的问题:真实用户在浏览器里点来点去,核心业务到底能不能走通?我见过不少项目接口测试全绿,结果页面按钮点了没反应、弹窗正好遮住提交按钮、某个字段因为前端校验直接卡死。这类问题,只有把真实浏览器跑起来才能抓到。

Selenium最擅长的是几类场景:核心业务链路的回归验证,比如登录、选购、下单、支付成功后的状态回跳;多浏览器兼容性验证,同一套流程在Chrome、Edge、Firefox上各跑一遍,确认没有某款浏览器专属的布局错位或交互失灵;表单交互相关的琐碎检查,包括必填校验、下拉选择、日期选择组件能不能正常操作,这类手工点吐了的操作,脚本跑起来反而更快。

反过来,Selenium不适合做什么也要心里有数。它不是性能测试工具——真实浏览器渲染加操作要消耗大量资源,想压出每秒几千次并发请求,那是JMeter或Locust的活,硬用Selenium去扛会把浏览器直接压垮。它也不适合做批量数据一致性校验,几千条数据逐条比对这种事情放到接口层或数据库层做,脚本又慢又脆。搞清楚工具的擅长边界,才不会在方案选型时做出拧巴的决定。

1.2 WebDriver工作的三角关系

理解Selenium的底层工作方式,能帮你避开一半以上的环境问题。Selenium本身不是一个浏览器,它是通过WebDriver协议和浏览器打交道的。当你调用driver.get(url)时,Python客户端把指令编码成HTTP请求,发送给浏览器驱动(ChromeDriver、GeckoDriver或EdgeDriver),浏览器驱动再把这个命令翻译成浏览器能懂的原生指令。

所以一套自动化环境里至少有四个角色:Python代码、Selenium库、浏览器驱动、真实浏览器。这四个角色里任何一个版本对不上,都会导致启动失败或者会话异常。尤其是Chrome驱动,必须和浏览器大版本号严格一致。浏览器驱动承担的是“翻译官”职责,驱动版本比浏览器旧,新浏览器引入的协议变动就翻译不出来;驱动版本太新,又可能读到不兼容的API。这个链条里只要掉一环,自动化就跑不起来。

1.3 什么情况下我劝你先别上Selenium

自动化不是越早越好。如果项目还在原型期,前端结构和DOM天天重构,脚本就像沙子垒的墙,改一版塌一版。我做过的项目里,凡是页面结构在三个版本内没有稳定下来的,自动化用例的维护成本都远超收益。所以我的判断标准是:核心流程至少在连续三个版本里没有大的结构变化,才具备做UI自动化的基础。

还有一种情况也劝退:项目以静态展示为主,几乎没有表单交互和动态跳转,那写自动化脚本就是典型的高射炮打蚊子,投入产出比极低。这种项目,与其养一套UI自动化,不如把手动探索测试做好,把关键路径录成操作文档都比特意维护脚本实在。自动化要解决的是“高频重复”问题,没有高频重复的地方,就不值得自动化。

1.4 老牌工具和后起之秀怎么选

现在聊Web UI自动化,绕不开Playwright和Cypress这些新秀。它们有一些很吸引人的特性,比如自动等待机制、更简洁的API、甚至开箱即用的网络拦截。但Selenium依然是我在很多项目里的首选,原因很实际:其一,它的语言绑定最全,Python、Java、C#团队都能用同一套理论;其二,它是W3C标准化协议,Chrome、Firefox、Edge、Safari这些官方浏览器都在配合维护,兼容性最稳;其三,生态多年沉淀,出问题搜一下基本都是现成答案,尤其团队里不止一个人协作时,这种“有据可查”的价值会被放大。

新框架确实解决了一部分Selenium的痛点,但Selenium的成熟度和跨浏览器覆盖能力,在老项目改造、企业级系统兼容性验证这些场景里依然是第一梯队。如果项目从零开始且团队愿意接受新工具,Playwright可以研究;但如果你的目标是快速在现有团队里普及Web自动化,Selenium仍然是最不容易踩坑的起点。下面所有实践方法,我也以Selenium 4.x为主线来讲。

2. 从零搭起Selenium环境:驱动版本匹配是第一道坎

2.1 装库很简单,麻烦在后面

环境搭建第一步是安装Selenium库。这里我很推荐先做虚拟环境,尤其是团队开发时,项目A可能用Selenium 4.x,项目B还在用3.x,混在同一个解释器里会互相踩版本依赖。

python -m venv venv source venv/bin/activate # Windows环境用 venv\Scripts\activate pip install selenium pip install pytest pytest-html

Selenium 4.x是目前的主流版本,它把一些历史包袱清理掉了,API比3.x更友好,比如Service对象、相对定位器都是4.x引入的,后面的示例代码也按4.x写法来。安装完库之后,直接运行webdriver.Chrome()大概率会抛一个和chromedriver相关的异常,这是新手遇到最多的第一个坑。

2.2 Chrome驱动的版本匹配实操

驱动异常的解决方案就一句话:浏览器驱动版本要和浏览器版本匹配。以Chrome为例,打开浏览器“关于Chrome”页面,看到类似120.0.6099.109的版本号,然后去官方驱动下载页找同大版本号的驱动,120.x.x.x即可。下载解压后,把chromedriver所在目录加进PATH环境变量,或者直接在代码里指定路径。

我不建议在代码里硬写驱动的绝对路径,换一台执行机器就崩。更好的做法是用webdriver-manager这个库,它会自动检测浏览器版本,下载并缓存对应驱动:

pip install webdriver-manager
from selenium import webdriver from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager service = Service(ChromeDriverManager().install()) driver = webdriver.Chrome(service=service)

这套组合能消掉九成以上的驱动管理痛苦。如果企业内网没有外网访问权限,那就把下载好的驱动统一放进一个目录,通过Service指定绝对路径,同时把版本核对步骤写进环境说明文档,谁来执行都先看一遍。

2.3 跑通第一个冒烟脚本

环境就绪后,写一个基础脚本验证整套链路:

from selenium import webdriver driver = webdriver.Chrome() try: driver.get("https://example.com") assert "Example Domain" in driver.title print("页面标题断言通过") finally: driver.quit()

driver.get负责打开指定地址,driver.title返回当前页面标题,driver.quit关闭浏览器。这里有个值得养成的习惯:把关闭动作放进finally,这样即使断言挂了,浏览器也不会残留一堆僵尸进程。看到控制台输出“页面标题断言通过”,你的第一套Selenium环境就跑通了,接下来才是真正艰难的定位和等待。

3. 元素定位决定测试生死:八种定位方式的取舍

3.1 八种定位方式的实战分类

自动化测试的本质是拿到页面元素,再对元素做点击、输入、读取动作。拿不到元素,一切都免谈。Selenium提供了八种定位方式,很多教程会平铺直叙列一遍,但实际项目里的使用频率完全不同,我自己排了个优先级:

定位方式写法示例适用场景
iddriver.find_element(By.ID, "username")表单字段、唯一控件
namedriver.find_element(By.NAME, "email")老项目里的表单
class_namedriver.find_element(By.CLASS_NAME, "btn")样式类有业务含义时
tag_namedriver.find_element(By.TAG_NAME, "a")遍历同类型元素
link_textdriver.find_element(By.LINK_TEXT, "登录")导航链接
partial_link_textdriver.find_element(By.PARTIAL_LINK_TEXT, "注册")链接文字会动态变化
css selectordriver.find_element(By.CSS_SELECTOR, "#app .cart")日常主力定位方式
xpathdriver.find_element(By.XPATH, "//button[contains(text(),'提交')]")按文本或层级关系定位

每类元素第一次写用例时,我都建议先在浏览器开发者工具里验证一下定位表达式。控制台执行$$('#selector')能快速确认CSS选择器是否命中目标元素,XPath可以用$x('//button[contains(text(),"提交")]')验证。这个习惯能省下大量反复运行测试的时间,因为定位写错一次,就要等环境完全跑起来才能发现。

3.2 为什么我把CSS Selector当作默认首选

CSS Selector能解决八成以上的定位问题,而且它有四个天然优势:一是语法简洁,一眼能读懂;二是在浏览器底层走的是querySelector机制,性能通常比XPath好;三是健壮性高,能容忍部分DOM结构微调;四是可以组合出复杂条件,比如“导航栏里class包含active的链接”,写成nav li.active a就足够了。

XPath看起来能做很多高大上的操作,比如按文本找元素、找父级兄弟节点、按模糊属性匹配,但实际项目里CSS能覆盖绝大部分需求。XPath真正不可替代的场景只有两类:按可见文本定位,比如//button[contains(text(),'立即购买')];向上遍历DOM找祖先节点,比如点击某个非标准控件后要回到它所在的表单容器去读状态。这两类情况就别硬用CSS凑,直接上XPath反而可读性更好。

我的准则是:优先id,其次是css selector,只有等遇到“按文本定位”或“按层级回找父级”的需求时才上XPath。class_name和tag_name这类范围太宽的定位,除非能确定页面里只有一个满足条件的元素,否则我几乎不用,命中范围太宽,很容易引发ElementClickInterceptedException这种莫名其妙的点击遮挡错误。

3.3 定位不到元素时,先别急着怀疑选择器

NoSuchElementException是最常见的报错,但多数时候问题不在定位表达式本身,而在定位时机。页面是异步的世界,按钮是Ajax渲染出来的,数据是接口返回后填充的,脚本跑得比页面快,就会出现“元素还没出生就去找它”的尴尬。所以遇到定位失败,我的排查顺序永远是:先看等待条件够不够,再看页面上是不是真的存在这个元素,最后才怀疑选择器写错了。配上失败截图看现场,通常能一眼看出是页面加载慢还是选择器笔误。

iframe是另一个容易栽的地方。页面嵌入iframe时,主文档的find_element看不到里面的内容,必须先切换到对应frame上下文:driver.switch_to.frame(...),操作完再切回主文档。Shadow DOM同样如此,默认定位方式够不到内部元素,需要先拿到shadow root再往下找。这类问题光改选择器没用,得先看懂页面的渲染结构,再决定用什么方式访问。

4. 把“碰运气”的等待变成可控条件:等待机制全解

4.1 sleep()为什么是坑

看过很多刚入门的脚本,喜欢在关键动作前加time.sleep(2)、time.sleep(5),仿佛睡够了页面就会乖乖就绪。这种写法的最大问题在于,等待时长和网络环境强相关:网络好的时候2秒足够,网络稍微一慢就报错;网络好到1秒加载完,又白白等了几秒。换台机器,脚本在A机器上全绿,到了B机器红一大片,这就是“碰运气”式等待的典型症状。解决这个问题不是改数字,而是换思路——等待的目标应该是一个条件,而不是一个固定时长。

4.2 显式等待:等到条件满足的那一刻

正确的思路是把“等几秒”变成“等什么条件”。WebDriverWait配合expected_conditions(EC)就是干这个的:

from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC wait = WebDriverWait(driver, 10) login_btn = wait.until(EC.element_to_be_clickable((By.ID, "loginBtn"))) login_btn.click()

这段代码的意思是:最多等10秒,每0.5秒检查一次,直到登录按钮可点击才继续。页面2秒就绪就是2秒完成,页面要8秒才就绪就等够8秒,而不是要么等不够要么等过头。这种等待方式写出来的脚本,稳定性会大幅提升。

常用的条件还有:presence_of_element_located,元素进入DOM就算满足,不一定可见;visibility_of_element_located,元素在页面上可见;text_to_be_present_in_element,元素里出现指定文本,适合等待后端数据回填;staleness_of,等待旧元素失效,通常用来判断页面是不是确实刷新了。实际项目里我一般还会封装一个自定义等待函数,统一处理“点击后加载数据”的常见场景,核心就是等到某个占位数据消失或目标数据出现,避免每个用例都重复写同一套等待逻辑。

4.3 隐式等待和显式等待的边界

隐式等待是给driver设置的一个全局轮询:每次find_element找不到元素时,会在设定时间内持续尝试,直到元素出现或超时。

driver.implicitly_wait(10)

它和显式等待最大的区别是作用范围:隐式等待对所有find_element生效,显式等待只对指定的WebDriverWait生效。这里有个一直被强调但依然有人踩的坑:不要在同一个脚本里混合使用隐式等待和显式等待。隐式等待轮询整个DOM,显式等待轮询某个条件,两者叠加会把等待周期的复杂度放大,偶尔就会出现明明条件满足却超时的诡异现象。我个人的规范是:统一用显式等待,不设置implicitly_wait。代价是多写几行代码,换来的却是可控和稳定。

4.4 StaleElementReferenceException和页面重新渲染

还有一个让新人抓狂的异常是StaleElementReferenceException:元素在定位时还好好的,等你要去点击的时候页面重新渲染了,旧元素引用已经失效。这个现象在单页应用里极其常见,典型场景就是点击“下一页”后列表重新加载,之前保存的翻页按钮引用直接作废。

解决办法通常是重新定位元素。如果循环里操作列表项,那就每次循环开始时都重新获取一次元素引用,而不是循环前拿一次就复用到底。理解这个原理比记住一个解决方案更重要:DOM是动态的,元素引用是“某一时刻的快照”,不是永久有效的指针。脚本要随时接受“页面结构变了,引用会失效”这件事,并在设计上留好重新获取的路径。

5. 脚本堆到框架:用Page Object和数据驱动改造用例

5.1 不做设计,脚本三个月就报废

很多测试脚本写多了以后都会遇到同一个噩梦:页面某个按钮的id改了,于是散落在几十个用例里的driver.find_element要全部改一遍。我见过一个项目因为前端改了用户名输入框的name属性,自动化用例组花了两天蹲着改脚本。这个痛苦的本质是,所有定位表达式都耦合在测试逻辑里,没有单独沉淀成页面层。

Page Object模式(PO模式)要解决的就是这个:把一个页面里的所有元素定位和操作封装成一个类,测试用例只跟这个类打交道,不再直接touch底层的find_element。这样页面结构变化时,改的是页面类这一个地方;业务用例增加时,只需要组合已有页面方法。

5.2 一个登录页面的Page Object长什么样

from selenium.webdriver.common.by import By class LoginPage: def __init__(self, driver): self.driver = driver self.username_input = (By.ID, "username") self.password_input = (By.ID, "password") self.login_button = (By.CSS_SELECTOR, "button[type='submit']") def open(self, url): self.driver.get(url) def login(self, username, password): self.driver.find_element(*self.username_input).send_keys(username) self.driver.find_element(*self.password_input).send_keys(password) self.driver.find_element(*self.login_button).click() def get_error_message(self): return self.driver.find_element(By.CSS_SELECTOR, ".error-msg").text

这里有两个细节值得展开讲。第一,定位器用元组先存起来,等到真正操作时才展开执行find_element,这样元素定位信息集中在类顶部那几行,页面变动只改一处。第二,暴露给测试用例的是login()、get_error_message()这类“业务动作”,测试代码读起来像在描述用户行为,而不是在描述HTML结构。

配合pytest使用,测试用例会非常干净:

def test_login_success(login_page): login_page.open("https://example.com/login") login_page.login("tester", "123456") assert "欢迎" in login_page.get_error_message()

当页面结构变化时,改LoginPage一个文件就够了;当业务用例增加时,只需要组合LoginPage的方法,不用重新发明轮子。这个模式带来的维护效率提升,用过的团队都体会很深。

5.3 数据驱动:一份数据跑N遍用例

PO模式解决了结构问题,另一个常见问题是测试数据写死在代码里。登录用例至少要覆盖正确密码、错误密码、空用户名、验证码错误等场景,如果一个场景复制一份脚本,维护成本会指数上升。数据驱动就是让“测试逻辑”和“测试数据”分离。

pytest的parametrize是现成的数据驱动工具:

import pytest @pytest.mark.parametrize("username,password,expected", [ ("tester", "123456", "登录成功"), ("tester", "wrong", "用户名或密码错误"), ("", "123456", "请输入用户名"), ]) def test_login_cases(login_page, username, password, expected): login_page.open("https://example.com/login") login_page.login(username, password) assert expected in login_page.get_error_message()

这样加一条新用例只需要加一组数据,逻辑完全不用动。如果数据量更大,可以把parametrize数据源改为读取外部JSON或YAML文件,测试代码和测试数据彻底分离。我通常会把测试数据放到项目根目录的data文件夹下,用统一的数据读取函数加载,比散落在各个py文件里好维护得多。

5.4 框架里的持续集成细节

搭框架时别忘了几个基础能力:统一截图,断言失败时自动保存现场;统一日志,记录每一步操作的元素和耗时;统一运行入口,让CI脚本一键执行而不是手动找用例。conftest.py里的fixture可以承载这些功能,比如用pytest的钩子收集失败用例并自动截图。这样半夜跑挂了,第二天打开报告直接能看清出错前的页面状态,而不是对着终端里几行报错猜原因。

6. 无人值守与稳定运行:无头浏览器、报告和定时任务

6.1 无头模式让测试在服务器上跑起来

跑自动化测试不一定总要弹出一个浏览器窗口。在CI服务器或定时任务里,我们希望它静默运行,Selenium的无头模式就是干这个的。新版Chrome从109版本开始推荐使用原生的新无头模式,参数是--headless=new,它比老的无头模式更接近真实浏览器行为,兼容性更好。

from selenium import webdriver from selenium.webdriver.chrome.options import Options options = Options() options.add_argument("--headless=new") options.add_argument("--window-size=1920,1080") options.add_argument("--disable-gpu") driver = webdriver.Chrome(options=options)

无头模式跑起来确实更省资源,但它不是银弹。有些页面的交互依赖真实渲染环境,比如某些验证码组件、某些依赖GPU能力的动画效果,无头模式下表现可能和真实浏览器有细微差异。我的做法是:日常调试用有头模式,出问题直接看浏览器真实行为;晚上定时回归用无头模式。调试和无人值守分开,能省掉不少定位问题的精力。

6.2 测试报告要能直接当证据

自动化测试跑完不能只输出几行PASS/FAIL就收工,测试报告要能回答三件事:哪些用例过了、哪些挂了、挂的时候页面长什么样。pytest的pytest-html插件能生成一份可浏览的HTML报告,加上--self-contained-html参数还能把所有样式内联进去,发出去就能看。

pytest --html=report.html --self-contained-html

再进一步,我在conftest.py里加一个失败截图钩子,用例执行失败时自动保存一张当时的页面截图,并把图片路径写进报告。这样回归跑挂之后,不用人工重新复现,报告里就能看到是弹窗遮住了按钮,还是数据加载太慢,还是页面结构又变了。有了这些现场证据,排查效率会高很多。

6.3 定时执行和通知怎么接到一起

本地手动跑测试不能算真正的持续回归,至少要让它在固定时间自动跑。最简单的方案是操作系统的计划任务:Windows用任务计划程序,Linux系统用cron。把执行命令写成shell脚本或bat文件,脚本里启动虚拟环境、执行pytest、生成报告,再把结果发给相关人员。邮件通知、企业内群机器人都可以接,关键是把失败信息第一时间推给对的人。

定时任务的调度频率我一般按项目节奏来:日常开发迭代期每晚跑一次全量回归,发版前再手动补跑一次核心链路的冒烟用例。太频繁全量回归会占用太多执行时间,太低频又失去了回归的意义。找到平衡点需要根据用例数量和执行时长逐步调整。

6.4 让整套用例从“能跑”变成“跑得稳”

最后分享几个让测试套件更稳的小经验。第一,用例之间不要互相依赖,每个用例都自己准备测试数据、结束后清理,保证用例能随机单独执行。第二,不要让两条用例同时操作同一个共享账号,并发执行时会出现意料之外的互相干扰。第三,对偶发失败不要太早投降,先观察失败截图和日志,如果是等待条件不足就优先修等待逻辑,而不是把用例一删了之。自动化测试的稳定率是慢慢磨出来的,一套用例跑到95%以上的稳定率,才能真正替你省下人工回归的时间。

这套Selenium自动化方案在真实项目里已经帮我扛过了几十个版本迭代,每次发布前跑一遍核心链路回归,都能发现不少手工测试容易漏掉的细节。如果你刚起步,别贪心,先把登录、注册、下单这三条高频变更链路用Selenium固化下来,比铺开几十个低质量用例有意义得多。等稳定成了习惯,再慢慢往外扩,也不迟。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询