做Web自动化的人,谁没被Selenium折磨过几回呢。我用这东西做了三年的自动化测试和页面巡检,自认为已经是“老司机”了,但每次新项目启动,还是能踩出几个新坑来。Selenium就像一把瑞士军刀,什么浏览器都能驱动,什么页面都能点一点,可它越是万能,暗坑就越多。这篇文章不聊高大上的理论,就聊聊我在实际做Web自动化、页面巡检、自动化测试框架时,真正踩过、填过、又踩穿的那些坑。
如果你是刚接触Selenium的小白,这篇文章能帮你绕开80%的早期雷区;如果你已经写了一阵子脚本,那这篇文章里关于等待策略、动态元素定位、无头浏览器配置的细节,大概率能解释你之前“明明没问题却一直报错”的困惑。我会从设计思路讲到元素定位,再到完整跑通的巡检脚本,最后给一份问题排查手册,全程都是实操视角,你直接抄作业就行。
1. 先理清楚:Selenium适合做什么,不适合做什么
1.1 它到底是什么,以及常见的三种用法
Selenium不是测试工具,它是一个“浏览器自动化协议”的实现。它的本质是通过WebDriver协议去操控真实的浏览器,模拟人的操作:打开页面、点击按钮、输入文字、读取页面数据。所以它能做的事比“测试”大得多。
我用它做过三类事情:
- 自动化测试:最传统的用法,跑回归用例,代替人工点点点。
- Web页面自动化巡检:这是最近两年用得最多的场景。每天早上自动登录后台系统,检查首页是否正常、核心接口是否返回正确、关键按钮是否可点。巡检的重点不是“功能正确性”,而是“页面可用性”,所以脚本稳定性要求极高。
- 数据采集/自动化脚本:比如批量下载报表、自动录入数据、定时处理工单。这类需求本质上也是Web自动化,只是不叫“测试”而已。
这三类场景对Selenium的依赖方式不太一样。测试讲究断言准确,巡检讲究长时间稳定运行,采集讲究速度和容错。但坑是通用的,下面要讲的每个坑,三类场景都躲不开。
1.2 为什么还用Selenium,而不是Playwright
很多人问我,现在Playwright和Puppeteer这么火,为什么还要用Selenium?我承认Playwright确实好用,它的自动等待机制、Shadow DOM穿透、移动端模拟都做得更现代。但Selenium有一个无可替代的优势:生态和兼容性。
你的团队里可能有人只会Python,有人只会Java,Selenium对全语言的支持意味着你不需要换技术栈。另外,很多企业内部系统是老旧架构,浏览器还停留在Chrome 90甚至更老,Playwright对这些老版本的支持远不如Selenium灵活。还有一个很现实的问题:市面上大部分现成的测试框架、云测平台、报告工具都优先兼容Selenium,和CI系统集成时资料最多,遇到问题能搜到的解决方案也最多。
一句话总结:如果做全新项目且没有历史包袱,Playwright确实可以优先考虑。但如果你的团队已经用Selenium跑了一堆用例,或者需要兼容老浏览器,老老实实把Selenium的坑填平,比推翻重来划算得多。
1.3 框架选型的思路:不要一上来就追求“完美架构”
我早期犯过一个错误:做框架时脑子里全是“最佳实践”,想着Page Object、数据驱动、关键字驱动、Allure报告、失败重跑,样样都要。结果花了两个星期搭了一个很“漂亮”的框架,真正写用例的时间反而被压缩了。
后来想明白了:Selenium脚本的核心不是框架多华丽,而是定位稳定、等待正确、异常可追溯。先跑起来,再逐步封装,这才是做Web自动化的正路。一个最简单的巡检脚本只有几十行代码,但它能稳定跑一个月不出问题,比一个5000行的框架每周挂一次更有价值。
2. 元素定位和等待策略:一半的坑都在这两个话题里
2.1 元素定位的优先级,我踩出来的经验
很多人学Selenium时,习惯用XPath,因为复制XPath最方便。但XPath是“最后一个选择”,不是“第一个选择”。我见过太多人因为复制全路径Xpath导致脚本跑两次就废了——页面结构稍微变动,路径就断了。
我日常用定位的优先级排序是:id -> data属性/name -> class/标签组合 -> 相对XPath -> 文本定位。为什么?id最稳定,页面设计者一般不会乱改;data属性次之,很多前端框架会自动生成随机id,这时data属性是救命的;class的问题是有多个值且经常变化;文本定位问题更大。
举个例子,一个登录按钮如果是这么写的:
<button class="el-button el-button--primary btn-login-20240901">登录</button>如果你用//button[contains(@class,'login')],class里的日期变了照样能匹配。但你如果用了形如html/body/div/div/div/div/form/div[3]/button这种全路径,下次页面加了个弹窗,这条路径直接失效。
2.2 iframe和Shadow DOM:两个“找得到但点不到”的重灾区
最气人的错误是NoSuchElementException,但更气人的是ElementClickInterceptedException——明明代码能找到元素,运行时就是点不到。这两个问题十有八九和iframe、Shadow DOM有关。
iframe必须先用switch_to.frame切进去,操作完再switch_to.default_content()切回来。嵌套iframe要一层一层切换,跳层会直接报错。
Shadow DOM是Selenium的老大难。老版Selenium面对Shadow DOM基本没辙,只能靠execute_script用JS穿透:
shadow_host = driver.find_element(By.CSS_SELECTOR, 'my-element') shadow_root = driver.execute_script('return arguments[0].shadowRoot', shadow_host) target = shadow_root.find_element(By.CSS_SELECTOR, '.inner-button')新版Selenium 4支持用find_element(By.SHADOW_ROOT, ...)直接拿影子根节点,好用多了。但如果遇到closed模式(闭合的Shadow DOM),用常规方式确实拿不到元素,这种时候只能调整策略:要么让前端加个data属性暴露出来,要么改用坐标点击,要么直接放弃这个元素的自动化。
2.3 显式等待和隐式等待:sleep大法害死人
我必须真诚地说一句:time.sleep(3)这种写法人人都干过,但它是最不靠谱的等法。网络慢一点3秒不够,网络快了又白等3秒,整个脚本的耗时被无谓拉长。我见过最夸张的项目,一个用例10个sleep,跑完要5分钟,实际操作只有30秒。
正确的做法是用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) wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, '#submit-btn'))).click()写等待条件时注意区分presence_of_element_located和visibility_of_element_located。前者只管DOM里有这个节点,不管它是否可见;后者要求元素在页面中可见且尺寸非零。如果你想点击,应该等element_to_be_clickable,它不但要求可见,还检查元素是否被禁用。
还有一个大坑:隐式等待和显式等待不要混用。隐式等待是查找元素时全局生效的轮询,显式等待是逻辑层的轮询,两个叠加会导致等待时间不可预测。我的习惯是只用显式等待,隐式等待一律设driver.implicitly_wait(0)。
def wait_and_click(driver, locator, timeout=10): el = WebDriverWait(driver, timeout, poll_frequency=0.2).until( EC.element_to_be_clickable(locator) ) el.click()这个简单的函数,我基本每个项目都会写一遍。它能解决大量“元素存在但点击失败”的问题,比如按钮一开始是disabled状态,0.2秒轮询会在它变成可点击的第一时间点下去。
2.4 动态ID、滚动加载、异步渲染的处理经验
现在的Web应用全是Vue/React这种前端框架做出来的,页面上充斥着随机生成的ID、异步加载的数据、滚动加载的列表。做这类页面的自动化,核心思路是“别跟动态的东西较劲”。
动态ID就跳过ID用相对XPath;异步渲染就用等待条件而不是固定等待;滚动加载可以先执行JS把它滚动到底再查找元素。我常用的滚动方法是:
driver.execute_script('window.scrollTo(0, document.body.scrollHeight)')还有一种情况:点击一个按钮之后,页面会弹出一个loading遮罩层,等接口返回后才消失,此时你立刻去点下一个按钮,大概率被遮罩拦截。我没找到太优雅的解法,比较实用的做法是等遮罩层的display:none或者等它消失:
wait.until(EC.invisibility_of_element_located((By.CSS_SELECTOR, '.loading-mask')))这个经验我在做后台管理系统时经常用到,遮罩层是自动化点击的头号杀手。
3. 从零搭一套能长期运行的Web巡检脚本
3.1 安装和版本匹配:环境配置的深坑
新手最常遇到的“SessionNotCreatedException”报错,根本原因就是浏览器版本和driver版本不匹配。Chrome从100版本以后,几乎每个大版本都对应一个独立的ChromeDriver版本,版本不对直接报错。
我早期每次Chrome自动更新,脚本就跟着挂一次,痛苦不堪。后来用了一个叫webdriver-manager的工具,它会自动检测浏览器版本并下载对应的driver,完美解决这个问题:
pip install selenium webdriver-managerfrom selenium import webdriver from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager driver = webdriver.Chrome( service=Service(ChromeDriverManager().install()) )另外注意,如果你用Windows系统跑定时任务,建议把driver放到一个固定目录并添加到系统PATH,而不是每次现下。这样即使断网也能正常跑。
3.2 无头模式和Docker容器里的参数选择
巡检脚本通常跑在服务器上,这就需要无头模式。Chrome 112版本以后推荐用--headless=new,我实测下来比老版稳定不少,对新版本和性能追踪的支持也更好:
from selenium.webdriver.chrome.options import Options options = Options() options.add_argument('--headless=new') # 新版无头模式 options.add_argument('--no-sandbox') # root用户运行时必须加,否则报错 options.add_argument('--disable-dev-shm-usage') # 容器/低内存环境必备 options.add_argument('--disable-gpu') options.add_argument('--window-size=1920,1080') # 有些页面在窄屏下布局会崩这几个参数每一个都有故事。--no-sandbox是因为服务器上经常用root用户跑任务,Chrome出于安全策略禁止root直接启动浏览器,必须加这个参数跳过沙箱;--disable-dev-shm-usage是Docker容器里常见的问题,默认/dev/shm只有64MB,跑了几个标签页就爆了,加了参数让Chrome改用其他临时目录;--disable-gpu在新版Chrome里加不加影响不大,但老环境里不加会出渲染莫名其妙的bug,所以我一律带上。
3.3 一个完整的巡检脚本示例
说一千道一万,给一个我实际在用的巡检脚本,每天自动登录后台,检查首页状态并截图:
import time from datetime import datetime from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager from selenium.webdriver.chrome.options import Options URL = 'https://admin.example.com/login' USERNAME = 'jobber' PASSWORD = 'your_password_here' def build_driver(): options = Options() options.add_argument('--headless=new') options.add_argument('--no-sandbox') options.add_argument('--disable-dev-shm-usage') options.add_argument('--disable-gpu') options.add_argument('--window-size=1920,1080') return webdriver.Chrome( service=Service(ChromeDriverManager().install()), options=options ) def check_login_and_dashboard(driver): wait = WebDriverWait(driver, 15, poll_frequency=0.5) driver.get(URL) wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, '#username'))).send_keys(USERNAME) driver.find_element(By.CSS_SELECTOR, '#password').send_keys(PASSWORD) wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, '.login-btn'))).click() # 登录成功后,等待侧边栏中出现“首页”菜单 wait.until(EC.visibility_of_element_located((By.CSS_SELECTOR, '.sidebar .dashboard-link'))) # 断言关键数据区域渲染出来 data_panel = wait.until( EC.visibility_of_element_located((By.CSS_SELECTOR, '.stats-panel')) ) text = data_panel.text.strip() assert len(text) > 0, '首页核心数据区域为空,巡检失败' # 截图留档 timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') driver.save_screenshot(f'dashboard_{timestamp}.png') driver = build_driver() try: check_login_and_dashboard(driver) print(f'巡检通过: {datetime.now()}') except Exception as e: driver.save_screenshot(f'failure_{datetime.now().strftime("%Y%m%d_%H%M%S")}.png') print(f'巡检失败: {e}') finally: driver.quit()注意几个细节:截图一定要放在finally之前或者异常分支里,方便事后排查;print的日志级别要分清楚,我推荐直接接入logging模块,方便以后对接日志系统;最后一定要driver.quit(),不要只写driver.close(),否则ChromeDriver进程会残留,跑几天之后机器上全是僵尸Chrome进程。
3.4 用pytest和Allure把脚本变成测试框架
巡检脚本基本稳定之后,我把它迁移到了pytest框架里,好处是可以自动收集失败信息、生成报告、接入CI。pytest的fixture机制天然适合做浏览器初始化和回收:
import pytest @pytest.fixture(scope='module') def driver(): d = build_driver() yield d d.quit() def test_login(driver): check_login_and_dashboard(driver)Allure报告是Selenium自动化使用率最高的报告工具,它能把每个用例的截图和日志归档到HTML报告里。我通常只做一件事:在断言失败和用例结束时自动截图并attach到Allure:
import allure def test_login(driver): try: check_login_and_dashboard(driver) except Exception as e: allure.attach(driver.get_screenshot_as_png(), name='failure_screenshot', attachment_type=allure.attachment_type.PNG) raise e这套组合(Selenium + pytest + Allure + Jenkins定时任务)完全够支撑中小团队的日常巡检和回归测试。如果你不想搭Jenkins,直接Windows计划任务或者crontab跑pytest命令也是一样的效果。
3.5 一个偏门的场景:Excel VBA里也能用Selenium
有段时间我用的是Excel VBA做内部工具的自动化,发现VBA也有Selenium的第三方封装,叫SeleniumBasic,可以直接在Excel里写Driver.FindElementById这样的代码。说实话,这个库的年代比较久远,基本处于停更状态,只支持老版浏览器,新Chrome版本兼容性很差。我的体验建议是:除非是你只能使用Excel环境、且IT策略不允许安装Python,否则不要在这条路上投入时间。VBA的字符串处理和异常处理远远不如Python方便,调试一个定位问题的时间够你写十个Python用例了。
4. Selenium问题排查手册:报错对照表
4.1 高频报错的根因和解决办法汇总
很多朋友遇到报错就蒙圈,其实Selenium的报错翻来覆去就那么几个,我把它们整理成了一张排查表:
| 报错类型 | 常见根因 | 解决办法 |
|---|---|---|
| SessionNotCreatedException | driver版本和浏览器版本不匹配 | 用webdriver-manager自动匹配,或手动下载对应版本 |
| NoSuchElementException | 元素定位符错误、页面没加载完成、在iframe里 | 检查定位符;改显式等待;switch_to.frame切入 |
| ElementClickInterceptedException | 元素被遮罩层或弹窗遮挡 | 等待遮罩层消失;用JS点击绕过;滚动到元素可见再点 |
| StaleElementReferenceException | 页面DOM刷新后,原先的元素引用失效 | 重新获取元素对象;避免在循环中复用元素引用 |
| TimeoutException | 网速慢、等待时间太短、异步渲染没完成 | 延长超时时间;细化等待条件 |
| InvalidArgumentException | URL格式错误或参数类型不对 | 检查driver.get()的地址是否带了合法协议(http/https) |
| WebDriverException | 浏览器进程崩溃、端口被占用 | 清理僵尸Chrome进程;重启驱动;换端口 |
那我自己来说,NoSuchElementException是出现频率最高的。排查它的思路是按照一条固定路径来的:先确认定位符在当前页面DOM里真实存在;再确认代码执行到那一步时页面是否已经加载完成;最后检查元素是不是在iframe或Shadow DOM里。按这个顺序查,90%的问题能快速定位。
4.2 一个印象深刻的排坑经历:诡异点击失效
有一段时间,我一个巡检脚本里的“保存”按钮总是偶发点击失败。当时页面是弹出一个抽屉式的表单,填完信息点“保存”。用显式等待后,element_to_be_clickable也通过了,但点击就是不生效,偶尔又成功,像玄学一样。
后来我发现问题的真正原因是:抽屉里的“保存”按钮被一个透明的遮罩元素盖住了。虽然Chrome判断这个按钮是“可见”的,但用户点击时,鼠标真正落到的却是上层的遮罩元素。Selenium的点击是直接派发事件给目标元素,而此时遮罩恰好拦截了事件,或者被Chrome判定为不可点,抛出ElementClickInterceptedException。
我的解决方式很简单粗暴但有效:
driver.execute_script("arguments[0].click();", save_button)JS的click()不模拟真实用户鼠标,而是直接调用该元素的DOM click事件,绕过覆盖问题。但要注意,它也不会触发浏览器对“元素是否真的可点”的校验,所以用之前要自己确认按钮不在禁用状态。代码里可以先用is_enabled()检查一下。
4.3 长时间运行的稳定性问题和定时任务的陷阱
巡检脚本连续跑一段时间之后,会遇到两类问题:一是Chrome进程内存涨得飞快,二是driver连接失效或卡死。内存问题通常可以用定期重启浏览器会话缓解,比如每执行50个用例就重新创建一次driver实例。卡死问题则要注意driver的加载策略:
options.page_load_strategy = 'eager'eager模式只在DOMReady之后立即返回,不等图片和样式表全部加载完。对于巡检场景,页面主体元素一般都在DOM里,这个策略能明显缩短单页等待时间,减少因某个外部资源拖垮整个页面的情况。
定时任务里还有一个特别容易踩的坑:crontab或计划任务执行时,环境变量和手动执行时不一样。ChromeDriver在PATH里的路径、Python解释器的路径、工作目录都可能不同,导致定时任务失败但手动跑又正常。我的习惯是在脚本开头统一设置:
import os os.environ['PATH'] = '/usr/local/bin:' + os.environ.get('PATH', '')或者在shell脚本里显式设置PATH和PYTHONPATH,这一步能省去无数“定时任务时灵时不灵”的烦恼。
4.4 能不用Selenium的地方就别用
最后一块内容,聊聊Selenium的边界。它的优点也是它的缺点:Selenium操纵的是真实浏览器,所以它重、慢、吃资源。如果一个任务只是请求HTTP接口,或者只需要检查接口返回的JSON,完全没有必要用Selenium,直接requests更轻。我的原则是:能用HTTP请求解决的,绝不开浏览器;只有在接口不好模拟、需要执行JS、需要真实浏览器环境时才启动Selenium。
比如做页面巡检时,我会先写一个纯请求的预检,检查服务器是否在线、接口是否返回200;只有预检通过后才用Selenium做页面级的完整检查。这样能大大减少无头Chrome的启动次数,降低整体资源开销。
做自动化巡检或者测试,Selenium的坑很多,但绝大多数坑都是可以被规则化的。定位用相对路径、等待用显式等待、操作前先确认iframe和遮罩层、跑完必quit、定时任务固定PATH——把这些基本动作变成习惯,脚本的稳定性会有一个质的提升。我个人的体会是,做Web自动化,最重要的不是写多少用例,而是让每一个用例都跑得足够久、失败时给的信息足够清楚。因为巡检的价值在于每天跑完给你一个可信的结果,而不是今天跑通明天就挂。这个方向做扎实了,再去追求框架、平台、数据驱动,才是有意义的。