1. 先把 driver 这个对象看透,不然方法背了也白背
刚接触 selenium 的同学,十个里有八个是这么入门的:打开教程,抄一段webdriver.Chrome(),然后开始往下面堆find_element,能跑起来就完事。等哪天脚本在一个弹窗页面卡死,或者报出一句unable to obtain driver for chrome,就彻底懵了。我自己也是从这个阶段过来的,所以这篇东西不打算按 API 文档的顺序念一遍,而是按"这个对象到底替我们干了什么活"来讲。
selenium 里的 driver,本质上是浏览器的一个远程遥控器。你的 Python 代码不会直接操作 DOM,它做的事情是把"点击某个按钮"这样的意图编码成一份指令,交给 driver 对象,driver 再通过一套标准协议把指令转发给浏览器内核,浏览器执行完把结果原路返回。理解这条链路非常重要,因为它解释了后面所有诡异现象:为什么有的方法会阻塞、为什么元素明明在页面上却点不到、为什么同一个方法在 Chrome 上是等待、在某个无头环境里却直接抛异常。
这篇文章适合三类人看:刚学 selenium 自动化测试、想把常用方法系统过一遍的新手;写了一段时间脚本、但方法用得比较野、遇到超时和定位失败只会加sleep的老手;以及需要给团队做一套稳定封装、想把 driver 能力边界摸清楚的人。下面我会把 driver 身上的方法拆成浏览器控制、元素查找、元素交互、等待机制、JS 与上下文切换几个大块,每一块都配上能直接抄的代码,也会把踩过的坑一起写进去。
1.1 先分清 Driver 实例、WebElement 和浏览器进程
很多人调方法时出错,根源在于没分清手上这个变量是什么类型。webdriver.Chrome()返回的是WebDriver 实例,它代表整个浏览器会话,能干的活是导航、切换窗口、管理 Cookie、截图、执行 JS。而driver.find_element(...)返回的是WebElement 实例,它只代表页面上的某一个节点,能干的事情就窄得多:点击、输入、读文本、读属性。
这两类对象的方法不能互相串用。我见过有人在 WebElement 上调用get(),也见过有人在 driver 上直接调send_keys(),报错信息还都挺隐晦的。判断标准很简单:凡是跟"整个浏览器"相关的动作归 driver,凡是跟"页面上某个具体元素"相关的动作归 element。截图这个事有点特殊,driver 可以截整个视口,element 也可以截自己那一块,这是 selenium 后期版本才补上的能力。
还有一个容易被忽略的点:driver 实例背后对应着一个真实的浏览器进程和一个驱动进程(Chrome 对应 chromedriver,Firefox 对应 geckodriver)。这两条命是绑在一起的,你的脚本异常退出时,如果不显式关闭,这两个进程很可能变成孤儿进程挂在系统里。跑批量用例的机器上,一天下来能攒出几十个僵尸浏览器,内存直接被吃光,然后你就开始怀疑是不是电脑该换了。
1.2 实例化方式的演进,以及那个恼人的驱动报错
早些年写 selenium,最烦的一步是手动下载 chromedriver,还得保证版本和本机 Chrome 严格对应,差一个小版本都可能起不来。现在 Selenium 4 引入了Selenium Manager,默认情况下会自动探测本机浏览器版本并拉取匹配的驱动,webdriver.Chrome()这一行就能直接跑通,对新手友好了非常多。
但热词里那个unable to obtain driver for chrome依然高频出现,我自己总结下来无非几种情况:一是公司内网没有外网出口,Manager 拉不到驱动文件;二是本机装的是某个定制版浏览器,版本号读出来对不上;三是环境变量里残留了旧版驱动的路径,Manager 找到了但启动失败。这时候最稳的做法是回到手动指定驱动的老路:
from selenium import webdriver from selenium.webdriver.chrome.service import Service as ChromeService 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") service = ChromeService(executable_path="/opt/drivers/chromedriver") driver = webdriver.Chrome(service=service, options=options)注意:Selenium 4 里
executable_path已经从webdriver.Chrome()的参数里挪到了 Service 对象上。网上大量老教程还在用旧写法,抄下来会直接报参数不合法,这是新手最常撞的墙之一。
2. 浏览器控制类方法:driver 最本职的那部分活
浏览器控制类方法是 driver 对象最"名正言顺"的职责,也是整个脚本的骨架。页面怎么打开、窗口多大、登录态怎么保住、出错了怎么留证据,全在这一层。这部分方法用得不讲究,后面写再多定位逻辑都是沙上建塔。
2.1 导航四件套:get、back、forward、refresh
driver.get(url)是最常用的方法,它会跳转到指定地址,并且默认会等页面基本加载完成才返回(严格说是等到 document.readyState 达到 complete 或超时)。这一点跟很多人想象的不一样——它确实带一点等待语义,所以偶尔会出现"没写等待也能跑通"的假象。但这个等待非常粗,它只管主文档,不管 AJAX 请求,也不管动态渲染的组件,所以后面该用显式等待的地方一个都不能少。
driver.back()和driver.forward()对应浏览器的后退和前进,driver.refresh()是刷新。这三个方法看着简单,实际有两个坑:一是它们的历史栈行为跟真实浏览器一致,如果你在中间做了 JS 跳转,栈的形态可能和你预期不同;二是刷新会丢掉页面上所有未提交的表单数据和 JS 运行时状态,脚本里无脑 refresh 结果把已填好的表单清空,这种事我干过不止一次。
还有一个细节值得记住:get()传的地址不带协议时,某些驱动会当成相对路径处理然后抛异常。养成习惯,URL 一律写全https://。
2.2 窗口尺寸、位置与全屏控制
窗口相关的方法有maximize_window()、minimize_window()、fullscreen_window()、set_window_size(w, h)、get_window_size()、set_window_position(x, y)、get_window_position()。看起来是很边缘的一类方法,实际在自动化里权重很高。
原因是响应式布局。同一个页面在 1366 宽度和 1920 宽度下,DOM 结构和元素可见性可能完全不同,尤其是那些移动端优先的站点,窄屏下会把某些按钮折叠进汉堡菜单里。你用默认窗口跑得好好的脚本,换台机器显示器不一样,定位就集体失效了。所以我的习惯是固定窗口尺寸:
driver.set_window_size(1920, 1080) # 或者 driver.maximize_window()这里有个实操经验要分享:maximize_window()在无头模式下不一定有效,某些驱动实现里窗口本身就是虚拟的,最大化不改变视口尺寸。无头环境里更可靠的做法是启动参数指定--window-size=1920,1080,而不是启动后再调方法。
还有一个多窗口场景的常用组合:
original = driver.current_window_handle # 当前窗口句柄 handles = driver.window_handles # 所有窗口句柄列表 driver.switch_to.window(handles[-1]) # 切到最新打开的标签页 driver.close() # 关掉当前标签页 driver.switch_to.window(original) # 切回去注意:
close()和quit()完全不是一回事。close()只关当前标签页,quit()关掉整个浏览器会话并回收驱动进程。脚本结束时务必用quit(),用close()在单标签页场景下看似也行,但驱动进程会残留。
2.3 Cookie 管理与登录态复用
add_cookie()、get_cookies()、get_cookie(name)、delete_cookie(name)、delete_all_cookies()` 这一组方法,是绕过重复登录的关键。自动化测试里最浪费时间的环节就是在每个用例开头登录一次,如果站点用的是普通 Cookie 会话,你完全可以登录一次、把 Cookie 存下来,后续直接注入。
思路是这样的:先跑一次登录流程,把driver.get_cookies()的结果序列化成 JSON 存到本地;之后的脚本启动后先get()到目标域名,再逐个add_cookie(),最后 refresh 一下让 Cookie 生效。
import json # 第一次:保存登录态 cookies = driver.get_cookies() with open("cookies.json", "w", encoding="utf-8") as f: json.dump(cookies, f, ensure_ascii=False) # 后续:恢复登录态 driver.get("https://example.com") with open("cookies.json", encoding="utf-8") as f: for ck in json.load(f): # 有些字段不能直接回写,先清理 ck.pop("sameSite", None) ck.pop("expiry", None) if isinstance(ck.get("expiry"), float) else None driver.add_cookie(ck) driver.refresh()必须在目标域名下加 Cookie,这是硬性约束。如果你先add_cookie再get,驱动会报当前域名不匹配。另外sameSite、expiry这类字段在不同驱动版本下兼容性不一致,回写前做一次字段清理能显著降低失败率,这是我踩了几次坑之后固定下来的做法。
2.4 截图、页面源码与当前地址
save_screenshot(path)、get_screenshot_as_file(path)、get_screenshot_as_png()属于同一族。区别在于get_screenshot_as_png()返回的是字节流,适合直接喂给图像库做处理或者上传到报告系统;save_screenshot()直接落盘,最省事。用例失败时自动截图是标配动作,建议放在测试框架的 teardown 里,用时间戳和用例名拼文件名,别问我为什么强调这个,问就是曾经覆盖过一整轮失败截图。
page_source是个属性不是方法,返回的是当前页面渲染后的 HTML 字符串。它的价值在于当定位失败时,把 page_source 落盘,你就能离线分析当时页面到底长什么样——很多时候问题根本不在你的选择器,而在页面还没渲染完,或者被弹窗遮住了。
driver.current_url和driver.title也是属性。断言时我更喜欢用current_url做跳转校验,因为title经常被前端改得面目全非,而 URL 的路径部分相对稳定。
3. 元素查找方法:定位是一切的入口
写完浏览器控制,就到了 selenium 的核心——把页面元素找出来。这部分方法不多,但组合方式和坑的数量是全书之最。
3.1 By 的八种定位方式和它们的取舍
Selenium 4 里推荐用By类配合find_element(By.XXX, "value")的写法。八种方式分别是 ID、NAME、CLASS_NAME、TAG_NAME、LINK_TEXT、PARTIAL_LINK_TEXT、CSS_SELECTOR、XPATH。网上很多对比文章会告诉你 ID 最快、XPath 最慢,这话在十年前成立,现在各家浏览器对选择器的优化都做得不错,性能差异在绝大多数场景下已经不是选择依据了,真正该考虑的是稳定性。
我自己的排序习惯是这样的:
| 定位方式 | 稳定性 | 适用场景 | 主要风险 |
|---|---|---|---|
| ID | 高 | 有唯一 id 的输入框、按钮 | 前端框架自动生成随机 id |
| CSS_SELECTOR | 高 | 大多数常规场景 | 层次深时表达式长 |
| XPATH | 中高 | 需要按文本或父级回溯定位 | 绝对路径极易失效 |
| NAME | 中 | 表单元素 | 页面上可能重复 |
| CLASS_NAME | 中低 | 快速试探 | 样式类名频繁改版 |
| LINK_TEXT | 中 | 纯文字链接 | 文字多语言变化 |
| TAG_NAME | 低 | 批量取同类元素 | 几乎不唯一 |
| PARTIAL_LINK_TEXT | 低 | 模糊匹配链接 | 容易误命中 |
提示:绝对不要用浏览器右键复制出来的绝对路径 XPath,那种
/html/body/div[3]/div[1]/ul/li[2]/a的写法,前端随便加一层 div 就全废。要用也是用相对路径配合属性或文本,比如//button[contains(text(), "提交")]。
CSS 和 XPath 之间怎么选,我的经验是:能用 CSS 就用 CSS,表达式短、可读性好;需要按文本内容定位、需要找父节点、需要按索引取第 N 个兄弟节点时,XPath 更方便,因为 CSS 本身不支持这些。
3.2 find_element 和 find_elements 的差异,坑都在这里
这两个方法名字只差一个 s,行为差异却很大,而且异常类型都不一样:
find_element找不到时抛NoSuchElementExceptionfind_elements找不到时返回空列表,不抛异常
后者这个设计经常被人当成救命稻草,写出类似"先 find_elements 看长度是不是大于 0,再决定点不点"的代码。这种写法本身没错,但它掩盖了一个问题:如果页面是异步渲染的,你查的那一瞬间元素还没出来,返回空列表,脚本就走了"元素不存在"的分支,静默地跳过了一个本该执行的操作。这种 bug 最难查,因为它不报错。
我的建议是:判断存在性用 find_elements,但一定要配合显式等待,不要用"查一次看长度"的方式。后面第 5 节会详细讲。
另外还有两个容易忽略的方法族:find_element在 WebElement 上也能调用,作用是在子树范围内查找。这在处理列表项时非常有用,比如先找到所有卡片,再在每张卡片内部找标题,避免全局匹配串到别的卡片上。
cards = driver.find_elements(By.CSS_SELECTOR, ".card") for card in cards: title = card.find_element(By.CSS_SELECTOR, ".title").text price = card.find_element(By.CSS_SELECTOR, ".price").text print(title, price)这个写法的好处是作用域收敛。如果你写成全局driver.find_elements(By.CSS_SELECTOR, ".title"),遇到卡片数量不固定或者存在隐藏卡片时,取出的顺序和数量很可能对不上。
3.3 实战:枚举一组非原生下拉框的元素
热词里提到"selenium 页面元素枚举""不是原生下拉框,是 div ul li 组合",这是个非常典型的场景,值得单独讲一段。现代前端组件库(不管是哪家)渲染出来的下拉框,绝大多数都不是<select>,而是一堆 div、ul、li 拼出来的。这意味着Select类完全用不上,你只能当成普通元素处理。
流程是这样的:点击触发区让下拉展开,等待选项列表出现,把所有选项枚举出来,再根据文本或属性匹配到目标项并点击。
from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC wait = WebDriverWait(driver, 10, poll_frequency=0.3) # 1. 展开下拉 wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, ".select-trigger"))).click() # 2. 等选项容器出现 options = wait.until( EC.presence_of_all_elements_located((By.CSS_SELECTOR, ".select-dropdown li")) ) # 3. 枚举并打印,方便确认渲染结果 for i, opt in enumerate(options): print(i, repr(opt.text), opt.get_attribute("data-value")) # 4. 按文本选中 target = "上海" for opt in options: if opt.text.strip() == target: opt.click() break else: raise AssertionError(f"下拉框中未找到选项: {target}")注意:这类组件经常在点击后把选项列表重新渲染一遍,导致你之前拿到的 WebElement 引用失效(报
StaleElementReferenceException)。规避办法是每次用完重新查找,不要跨操作复用元素引用。
还有一个细节:presence_of_all_elements_located只保证元素出现在 DOM 中,不保证可见。如果组件用了懒渲染,可能出现"元素在但不可见"的情况,这时候应该换成visibility_of_all_elements_located或者干脆用element_to_be_clickable对单个元素做判断。
4. 元素交互方法:从点击到拖拽
找到元素只是第一步,真正让脚本产生业务价值的是交互。这一层方法的数量不多,但每个都有自己的一套脾气。
4.1 click、send_keys、clear 的细节与陷阱
click()是最常用的方法,它执行的是原生点击事件,会滚动到元素位置、模拟鼠标移动和按下抬起。但在真实项目里,click()报"元素不可交互"(ElementNotInteractableException)的概率相当高,常见原因有这么几种:元素被其他浮层遮住了、元素尺寸为零、元素在可视区外但容器用了overflow: hidden导致滚动无效、页面还在动画过程中。
我的处理顺序是这样的:先判断是不是浮层遮挡,把遮挡物关掉是最干净的解法;如果确实是渲染动画导致的,用显式等待等它稳定;实在不行再用 JS 点击兜底:
element = driver.find_element(By.CSS_SELECTOR, "#submit") try: element.click() except Exception: driver.execute_script("arguments[0].click();", element)但要注意,JS 点击是绕过浏览器事件模型的,它不会触发某些由真实鼠标事件驱动的逻辑(比如某些框架的焦点管理、hover 态切换)。所以它只能当兜底方案,不能当默认方案。我见过团队成员把所有 click 都换成 JS 点击,结果表单提交后的校验逻辑全不触发,排查了一下午。
send_keys()用来输入文本,它也是模拟真实键盘输入,逐个字符敲进去。这个特性在大多数场景下是优点,因为会触发前端绑定的 input 事件;但在长文本或需要粘贴大段内容的场景下很慢,可以用execute_script直接设值再手动派发事件来加速。清空输入框不要用send_keys(Keys.CONTROL, "a")再删,直接用clear()更稳,因为组合键在某些驱动和系统下会失灵。
4.2 Select 类只吃原生 select,这点必须记牢
Select类提供select_by_index、select_by_value、select_by_visible_text、deselect_*以及options、all_selected_options、first_selected_option这些属性。功能很全,但它的前提是元素必须是真正的<select>标签。
怎么确认?在浏览器开发者工具里选中那个下拉框,如果标签名是 select,里面是 option,那就能用;如果是 div 套 ul 套 li,Select初始化的时候就会直接抛UnexpectedTagNameException。这个异常信息比较直白,看到它基本就能确定是在非原生下拉框上误用了。
from selenium.webdriver.support.ui import Select sel = Select(driver.find_element(By.ID, "city")) sel.select_by_visible_text("杭州") print([o.text for o in sel.options]) # 枚举全部选项 print(sel.first_selected_option.text) # 读当前选中项sel.options这个属性正好对应"页面元素枚举"的需求,比手写 CSS 选择器再筛选更省事,前提依然是原生 select。
4.3 ActionChains:悬停、拖拽、组合键
ActionChains处理的是那些单次点击搞不定的操作:鼠标悬停展开菜单、拖拽排序、按住 Ctrl 多选、右键菜单。它的使用方式和普通方法差别很大,链式调用最后必须perform()才会真正执行,这一点新人极容易漏。
from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.keys import Keys actions = ActionChains(driver) actions.move_to_element(menu).pause(0.5).click(sub_item).perform() # 按住 Ctrl 多选 ActionChains(driver).key_down(Keys.CONTROL).click(item1).click(item2).key_up(Keys.CONTROL).perform() # 拖拽 ActionChains(driver).drag_and_drop(source, target).perform()注意:
pause()在链式操作里比time.sleep()好用得多。它在动作序列内部插入了等待,不会阻塞整个 Python 线程去做无关的等待,而且能让动画有时间反应。悬停菜单类场景,我基本都会在move_to_element之后加 300 到 500 毫秒的 pause。
5. 等待机制:把 driver 方法串起来的胶水
如果只能给 selenium 新手留一条建议,我会说:先学会等待,再学定位。绝大多数"元素找不到""点不到"的问题,本质都不是方法用错了,而是时机没对上。
5.1 隐式等待和显式等待的本质区别
driver.implicitly_wait(10)设置的是全局的隐式等待。它的行为是:每次调用查找元素的方法时,如果没立刻找到,就在这个时间窗口内轮询重试,超时后抛异常。它只对查找元素生效,对点击、对元素可见性、对文本变化一律无效。
WebDriverWait是显式等待,它把"等待条件"显式表达出来:等到某个条件为真,或者超时抛异常。条件由expected_conditions提供,常见的有presence_of_element_located、visibility_of_element_located、element_to_be_clickable、text_to_be_present_in_element、invisibility_of_element_located、staleness_of等等。
两者的关键差异我整理成了一张表:
| 维度 | 隐式等待 | 显式等待 |
|---|---|---|
| 作用范围 | 全局,所有元素查找 | 单次调用,精确控制 |
| 生效范围 | 仅查找元素 | 查找、可见性、可点击、文本、消失等 |
| 超时行为 | 抛 NoSuchElement | 抛 TimeoutException |
| 与另一个混用 | 会叠加,导致总等待时间不可控 | 建议只留一种 |
混用是重灾区。如果你设了隐式等待 10 秒,同时显式等待也设 10 秒,某些情况下单次查找的实际耗时可能达到 20 秒,因为隐式等待会在显式等待的每一轮轮询里再各自消耗一遍。我的建议是:新项目一律不用隐式等待,全部用显式等待,代码啰嗦一点,但行为完全可控。
5.2 自己写等待条件,处理那些标准条件覆盖不了的场景
expected_conditions覆盖了八成场景,剩下两成得自己写。写法很简单:一个接收 driver 的可调用对象,返回真值表示条件满足,返回假值就继续等。
def element_count_more_than(locator, n): def _predicate(driver): els = driver.find_elements(*locator) return els if len(els) > n else False return _predicate wait.until(element_count_more_than((By.CSS_SELECTOR, ".row"), 5))这个"数量大于 N"的条件在列表加载场景里特别好用,比等某一个具体元素要稳,因为列表页经常是分批渲染的。另一个常用的是等属性变化:
def attribute_equal(locator, attr, value): def _predicate(driver): el = driver.find_element(*locator) return el.get_attribute(attr) == value return _predicate wait.until(attribute_equal((By.ID, "status"), "data-state", "done"))5.3 封装一层,让脚本不再满屏都是 wait
显式等待的代码写起来比较长,如果每个操作都写一遍,脚本可读性会崩掉。我一般会封装一层薄薄的工具方法,把"查找 + 等待"打包:
class Ui: def __init__(self, driver, timeout=10): self.driver = driver self.wait = WebDriverWait(driver, timeout, poll_frequency=0.3) def find(self, by, value): return self.wait.until(EC.presence_of_element_located((by, value))) def click(self, by, value): self.wait.until(EC.element_to_be_clickable((by, value))).click() def type(self, by, value, text): el = self.wait.until(EC.visibility_of_element_located((by, value))) el.clear() el.send_keys(text) return el def texts(self, by, value): els = self.wait.until(EC.presence_of_all_elements_located((by, value))) return [e.text for e in els]这层封装带来的最大变化是业务脚本里几乎看不到等待代码了,同时每个操作都天然带着合理的等待语义。注意click用的是element_to_be_clickable而不是presence,这是刻意的:点击这个动作对元素的要求比单纯存在高,用对条件能省掉大量零散的异常处理。
6. JS 执行、上下文切换与其他进阶方法
到这一层,方法的使用频率开始下降,但关键时刻能救命,尤其是页面结构特殊或者需要绕过常规交互的时候。
6.1 execute_script:几乎是万能补丁
execute_script(script, *args)在浏览器里执行 JS,返回值会回传给 Python。它在几类场景下特别有用:
- 元素在视口外,常规点击滚动不到:
arguments[0].scrollIntoView({block: "center"}) - 需要直接读取或改写元素属性、样式
- 需要一次性拿到大量数据,避免反复跨进程通信
# 滚动到元素 driver.execute_script("arguments[0].scrollIntoView({block:'center'});", el) # 取页面滚动高度 h = driver.execute_script("return document.body.scrollHeight") # 一次性把列表文本取回来,比逐个 element.text 快得多 texts = driver.execute_script(""" return Array.from(document.querySelectorAll('.item .title')) .map(e => e.innerText.trim()); """)最后这个批量取值的技巧值得单独说一句:每次element.text都是一次跨进程调用,一百个元素就是一百次往返,慢得明显。用execute_script一次取回,速度差别在数据量大的时候非常直观。execute_async_script是异步版本,接收一个回调参数,适合等一个前端异步操作完成,但实际项目里用得很少,显式等待基本能替代。
6.2 switch_to 三兄弟:window、frame、alert
driver.switch_to下面挂了三个常用的切换对象。
switch_to.window(handle)切标签页,前面已经讲过,注意切完之后所有元素查找都是在新的上下文里进行。
switch_to.frame(...)进入 iframe。iframe 是定位失败的头号嫌疑犯,因为 iframe 内部的 DOM 对外层来说是完全不可见的。判断方法很简单,看看你定位的那个元素是不是在一个<iframe>里面。进入方式支持索引、name、WebElement 三种,元素方式最稳:
iframe = driver.find_element(By.CSS_SELECTOR, "iframe.pay-frame") driver.switch_to.frame(iframe) # ... 在 iframe 内操作 driver.switch_to.default_content() # 回到最外层 # driver.switch_to.parent_frame() # 回到上一层注意:
default_content()和parent_frame()的区别是,前者直接回到最顶层,后者只退一层。iframe 嵌套三层以上的页面,退的时候一定要数清楚退了几层,否则元素查找全在一个错误的上下文里,报错信息又看不出问题所在。
switch_to.alert用来处理浏览器原生弹窗,accept()确认、dismiss()取消、send_keys()输入、text读文本。原生 alert 会阻塞整个页面的 JS 执行,所以如果脚本卡住不动,先想想是不是有弹窗没处理。
6.3 超时设置、日志和其他实用的边角能力
driver.set_page_load_timeout(n)控制页面加载的最长等待,某些加载特别慢的页面(各种第三方统计脚本一直挂在那儿)会把 get() 卡到超时,设一个合理的值然后在 TimeoutException 里继续走,是很实用的止损策略。
driver.set_script_timeout(n)对应 execute_async_script 的超时。driver.implicitly_wait(n)前面说过了。
日志和性能数据属于可选能力,需要在启动参数里开启:
options.set_capability("goog:loggingPrefs", {"browser": "ALL", "performance": "ALL"}) ... for entry in driver.get_log("browser"): print(entry["level"], entry["message"])浏览器控制台的报错信息对排查前端问题非常有用,比如某个接口 404 导致列表加载不出来,页面上看不出任何异常,控制台里一清二楚。我习惯在用例失败时把日志和 page_source 一起存下来,事后分析省掉大量复现时间。
7. 报错排查速查与实操心得
方法讲完了,最后这块是我觉得比 API 本身更值钱的部分。下面这张表是我这些年攒下来的高频报错对照,基本覆盖了日常八成的问题。
7.1 高频异常速查表
| 异常 / 现象 | 真正的原因 | 处理思路 |
|---|---|---|
| NoSuchElementException | 选择器错、iframe 内、未渲染完 | 先看 page_source,再查 iframe,最后补显式等待 |
| ElementNotInteractableException | 被遮挡、尺寸为零、动画未结束 | 关浮层、等可见可点击、JS 点击兜底 |
| StaleElementReferenceException | 页面局部刷新导致元素引用失效 | 用完即弃,不要缓存 WebElement |
| ElementClickInterceptedException | 有其他元素挡在点击位置 | 用 element_to_be_clickable,或滚动后重试 |
| TimeoutException | 条件始终不满足 | 检查条件是否写错,必要时用自定义条件 |
| UnexpectedTagNameException | 对非原生下拉框用了 Select | 改成通用的点击 + 枚举方案 |
| unable to obtain driver for chrome | 驱动不可得或版本不匹配 | 手动指定 chromedriver 路径 |
| 脚本跑完进程残留 | 没调 quit() | teardown 里统一 quit,异常也要保证执行 |
关于最后一条,补充一个写法上的经验:把quit()放进finally块或者测试框架的 teardown,比在脚本末尾直接写一行要可靠得多,因为中间任何一步抛异常都会跳过末尾那一行。
7.2 踩坑之后总结出来的几条硬经验
第一条,别用 sleep 当等待。time.sleep(3)看着简单,但它是无条件等待,页面 0.5 秒就加载完了你也要白白等 3 秒,脚本一长总时间直接翻倍;而遇到慢一点的页面 3 秒又不够,你还是得改成 5 秒,最后到处都是魔法数字。显式等待是条件驱动的,快的时候立刻返回,慢的时候顶到上限,这才是正解。
第二条,元素不要缓存。我早期写脚本喜欢把常用的元素存成变量复用,结果在 SPA 项目里被StaleElementReferenceException教育了好几次。凡是页面会局部刷新的项目,一律现查现用。如果确实需要跨多次操作使用,缓存定位器(By + value)而不是缓存元素本身。
第三条,定位器要当成代码资产来维护。建议统一放在一个模块里集中管理,比如LOCATORS = {"login_btn": (By.ID, "login")},业务脚本只引用键名。这样页面改版时只需要改一个文件,而不是全局搜索替换。这个习惯我坚持了几年,维护成本至少降了一半。
第四条,调试的时候把 headless 关掉。无头模式跑得快,但出问题时你什么都看不见。定位失败时切回有头模式,肉眼看一下元素在不在、有没有被遮挡,比盯着报错信息猜要高效得多。现在 Selenium 4 支持--headless=new,新版本的有头无头渲染路径基本统一,但调试阶段我还是建议开着界面。
第五条,注意页面上"看起来一样"的多个元素。同一个按钮可能在页面顶部导航、底部导航、弹窗里各出现一次,CSS 选择器如果只写了类名,find_element拿到的很可能是第一个,也就是你不想点的那个。所以选择器尽量带上父级限定或者业务属性,别图省事。
最后分享一个我自己常用的小工具方法,用来在定位失败时快速看清现场:
def dump_context(driver, tag="debug"): import time ts = time.strftime("%Y%m%d_%H%M%S") driver.save_screenshot(f"{tag}_{ts}.png") with open(f"{tag}_{ts}.html", "w", encoding="utf-8") as f: f.write(driver.page_source) print("当前地址:", driver.current_url) print("当前标题:", driver.title)出问题的时候把这段挂上去,截图、源码、URL、标题一次性拿到,大多数定位问题看一眼 HTML 就能确定原因,是 iframe、是动态 id、还是压根还没渲染出来。这个习惯帮我省下的时间,比学任何一个新方法都多。