尧图精选

Selenium三种等待详解:sleep、隐式等待与显式等待避坑

🕒 发布时间:2026/10/2 4:27:45 📁 来源:尧图网络
脚本在本地跑得好好的一扔到持续集成环境就开始报 NoSuchElementException同一个按钮昨天能点到今天非得停两秒才点得到。写 selenium 自动化测试的人基本都经历过这个阶段然后条件反射地在报错行上面补一句time.sleep(3)跑通了收工。可等到用例涨到两三百条整套回归从十分钟涨到四十分钟又开始怀疑人生。这篇就围绕 selenium 里三种等待方式——sleep、implicitly_wait、WebDriverWait——把它们的真实作用范围、失效边界、混用后果掰开讲清楚顺带把下拉框渲染、元素枚举、定位元数据管理这些容易一起踩的坑一并说透。不管你是刚装好 selenium 准备写第一个脚本还是已经维护着一套不太稳的老用例都能从里面挑到能直接抄的写法。1. 定位失败的锅通常不在选择器上1.1 页面渲染和代码执行之间那条时间缝绝大多数人第一次遇到元素找不到第一反应是选择器写错了于是改 XPath、改 CSS、加层级折腾半天发现选择器根本没问题。真正的问题在于浏览器把 HTML 交给你的时候页面只是存在不是可用。这中间隔着好几层时间差。第一层是接口数据回来之前SPA 页面渲染的是骨架屏。driver.get()在页面主文档的加载事件完成时就返回了可首屏数据往往还要等一个异步请求请求回来之后前端框架才开始做 DOM 差异更新。这期间你去找商品列表、去找用户信息DOM 里压根没有这些节点。第二层是 DOM 更新之后的样式计算和布局。节点插进 DOM 了但它的宽度高度可能还是 0或者父容器带着display: none这时候click()上去要么直接抛异常要么点到空气上。很多点击没反应但不报错的情况都出在这里。第三层是交互状态。按钮在 DOM 里、也可见但带着disabled属性或者被一个透明遮罩层压着——遮罩层是前端常见的做法加载中盖一层半透明 div 挡住点击。这时候元素对象一切正常就是点不动。三层时间差对应三种不同的等待需求这是理解三种等待方式的前提。用错维度去等就会得出隐式等待没生效这种结论其实它一直在生效只是管不了你要管的那件事。1.2 三种等待各自解决哪一段问题time.sleep是线程级阻塞implicitly_wait挂在查找元素这个动作上WebDriverWait挂在某个条件成立上。三者的抽象层级完全不同这一点搞明白了后面的坑基本都能自己推出来。对比项sleepimplicitly_waitWebDriverWait作用对象当前线程元素查找动作任意可判断的条件单位秒可小数秒全局一次设置秒每次调用单独设是否感知页面状态完全不感知只感知元素是否找到可感知可见、可点、文本、数量等超时后行为无超时概念必然等满抛 NoSuchElementException抛 TimeoutException能否覆盖 alert、iframe、URL 变化不能不能能典型误用用它替代一切等待设成 30 秒当保险和其他两种混用不查冲突把这张表记住遇到为什么我等了还是找不到的时候先对照一遍你要等的那件事落在哪一列的能力范围内。落在范围外的再怎么调参数都没用。提醒等待的本质是缩小期望状态和当前状态之间的时间差不是把时间拉长。凡是靠加时长解决的问题都会在更慢的环境里重新变成问题。2. sleep最直白也最容易埋雷的一种等待2.1 sleep 到底做了些什么time.sleep(5)做的事只有一件让当前线程休眠五秒期间把 GIL 让出去CPU 不做无意义的空转。它跟浏览器、跟页面、跟网络请求没有任何关系。哪怕页面在第 0.1 秒就加载好了第 5 秒才会执行下一行。我在早期的脚本里大量用过这个因为它的心智负担确实低——报错了就在上面加一行跑通为止。问题是这种加时长的修复方式没有自我收敛的机制。今天本地跑得通明天换台机器就未必这条用例跑得通换个网络环境又未必。每次出问题都要再加一点加到后来一个用例里躺着七八个 sleep光等待就占了十几秒。from time import sleep driver.find_element(By.ID, login-btn).click() sleep(2) driver.find_element(By.ID, username).send_keys(demo)这段代码在本地环境可能一辈子不出错但它的稳定性是借来的不是挣来的。借的是这台机器 这个网络 这个时段的后端响应速度这个组合恰好稳定的光。2.2 sleep 仍然有资格出现的三类场景我不是要把它一棍子打死有几种情况它反而是正确选择因为它们等的是确定性的时间不是猜测的状态。第一种是外部系统的固定冷却间隔。比如验证码一分钟内只能发一次或者某个业务接口约定两次调用之间要隔三秒。这种等待是业务规则定义的跟页面状态无关用什么显式条件都表达不出来老老实实sleep就行顺便在注释里写清依据。第二种是前端固定时长的动画而且动画过程中 DOM 没有任何可观测变化。典型是 canvas 绘制的图表、纯 CSS 的加载动画、游戏化的交互反馈。这时候确实没有条件可等只能等时间过去。遇到这种我更倾向于把等待时长写成常量并从配置里读方便不同环境微调。第三种是调试阶段的临时阻断。想看清楚某一步操作后页面的中间状态插一个sleep打断执行流比在调试器里单步更顺手。但这类代码在上线前必须清掉我见过不止一个项目把调试用的 sleep 带进了主干分支。提示判断一个 sleep 该不该留问自己一句话——这个等待时长是业务规定的还是我猜的业务规定的留猜的换掉。2.3 用 sleep 撑起来的脚本会在哪里碎掉最直接的代价是时间。一条用例里平均五个sleep(2)就是十秒纯浪费。三百条用例下来是五十分钟。我手上有一个真实的回归套件把散落的 sleep 逐步替换成显式等待之后整体耗时从四十来分钟压到九分钟左右代码行数还少了一截因为很多 sleep 上面跟着的 try/except 重试逻辑也一并删掉了。第二个代价是环境适应性。本地开发机通常比测试环境快得多同样的sleep(3)本地是奢侈测试环境是勉强容器化的持续集成环境里直接不够。于是有人把值加到十秒本地那边就变成每条用例白等十秒。这个死循环没有出口唯一的方向是让等待跟着页面状态走而不是跟着时间走。第三个代价是掩盖真实缺陷。如果某个元素在正常情况下需要两秒才出现而现在固定等两秒说明这个页面已经处在临界状态了。加了 sleep 之后这个信号被抹掉了等它恶化到需要三秒的时候才会重新暴露而那时候你手上已经没有基线数据来判断是代码退化还是环境变慢了。3. implicitly_wait一处设置、全局生效的隐式等待3.1 它的作用范围比大多数人以为的要窄driver.implicitly_wait(10)这行代码做的事是告诉这个 driver 实例以后每一次查找元素如果没找到就先别抛异常在接下来的十秒内不断重试直到找到或者超时。关键在查找元素这四个字。它的作用范围严格限定在元素定位上具体表现是这样的对find_element生效找不到时先重试再抛NoSuchElementException。对find_elements也生效但找不到时超时后返回空列表不抛异常。这点很容易踩很多人以为它会抛异常然后写了个except去兜底结果没兜住后面拿空列表下标访问才报错。对click()、send_keys()、text取值这些操作完全不生效。对元素的状态变化完全不生效。元素在 DOM 里但不可见、不可点查找动作会立刻成功返回然后操作才失败。对 alert 弹窗、窗口切换、frame 切换、URL 变化完全不生效。最后两条是隐式等待看起来没生效的绝大部分原因。它不是没生效是你等的东西不在它的职责范围内。3.2 设置位置和轮询节奏的细节隐式等待有三个使用上的硬性约束。第一必须在创建 driver 之后尽早设置最迟也要在第一次查找元素之前。如果某次查找已经发生在设置之前那次查找不受影响。第二设置一次就够它的作用域是整个 driver 生命周期包括后续通过driver.get()打开的新页面。第三它的轮询间隔不可配置实测表现大约每 500 毫秒重试一次。轮询间隔不可配置这件事带来一个隐性代价如果你的页面元素通常在 100 毫秒内就出现隐式等待依然可能让你多等最多 500 毫秒才拿到结果。单次看不多几千次查找累积起来就很可观了。这也是为什么现在更推荐用显式等待——显式等待的轮询间隔可以自己调到 0.1 秒甚至更小。from selenium import webdriver driver webdriver.Chrome() driver.implicitly_wait(5) # 放在这里之后所有查找动作都受它约束 driver.get(https://example.com/list)3.3 隐式等待失效的四个典型现场把这四个现场记下来能省掉很多无谓的参数调优。现场一alert 弹窗。隐式等待管不了系统级弹窗必须用WebDriverWait配合alert_is_present或者driver.switch_to.alert外面套显式等待。现场二iframe 里的元素。如果目标元素在 iframe 里而你没有切进去find_element会一直找不到隐式等待会把十秒钟耗完然后抛异常。这时候该做的是先等 iframe 就绪并切换再找元素。现场三元素存在但不可交互。前面说的骨架屏、遮罩层、disabled状态都属于这一类。查找动作秒成功操作秒失败。这种必须换显式等待的可见性条件或可点击条件。现场四非原生下拉框的列表项。这种结构通常是外层div常驻 DOM内层ul用display: none藏着。查找ul能找到查找li也能找到只是都不可见。隐式等待在这时候完全帮不上忙页面看起来元素都在就是点不了。第六节会专门讲这个场景该怎么处理。4. WebDriverWait按条件轮询的显式等待4.1 构造函数四个参数的完整含义from selenium.webdriver.support.wait import WebDriverWait wait WebDriverWait( driver, timeout10, # 最长等待时间 poll_frequency0.5, # 轮询间隔 ignored_exceptionsNone # 默认 [NoSuchElementException] )timeout是总时长上限超了抛TimeoutException。poll_frequency默认 0.5 秒我一般在自己封装里改成 0.1 到 0.3 秒响应更快而且不会给浏览器带来明显压力。设到 0.01 这种量级就没必要了反而会因为高频执行条件函数带来额外开销。ignored_exceptions是被最多人忽略的一个参数。它的默认值是只忽略NoSuchElementException也就是说轮询过程中如果抛出别的异常会立刻中断并向上抛不会重试到超时。这一点非常反直觉因为默认值让人以为任何异常都会重试。我踩过这个坑写了个自定义条件去等列表加载完成里面调了element.get_attribute(class)偶尔因为页面局部刷新导致元素失效抛StaleElementReferenceException。结果整个等待在 0.2 秒内就崩了报错信息跟超时完全不一样。解决方法就是把它加进忽略列表from selenium.common.exceptions import StaleElementReferenceException wait WebDriverWait( driver, timeout10, poll_frequency0.2, ignored_exceptions[StaleElementReferenceException] )注意一旦手动传了ignored_exceptions默认的NoSuchElementException就不在里面了需要一起补上否则最常见的场景反而失效。这个细节我在两份不同的代码库里都见过有人写错。4.2 常用条件与适用场景对照selenium 自带的expected_conditions模块覆盖了绝大多数场景下面这些是我实际用下来频率最高的。条件判断依据典型场景presence_of_element_located节点存在于 DOM只需读取隐藏域的值visibility_of_element_located节点存在且 is_displayed 为真等待内容区渲染出来element_to_be_clickable可见且 enabled点击按钮、链接invisibility_of_element_located节点不存在或不可见等加载遮罩消失text_to_be_present_in_element元素文本包含指定串等状态文字变成已完成element_attribute_to_include属性值包含指定串等 class 里出现 activestaleness_of元素已从 DOM 中移除等待旧列表被替换frame_to_be_available_and_switch_to_it可切换并自动切进去iframe 表单number_of_windows_to_be窗口数量匹配等新标签页打开alert_is_present出现 alert系统确认框presence和visibility的区别值得单独说一句因为它俩换错导致的 bug 特别隐蔽。presence只看节点在不在 DOM 树里节点可能是零尺寸或者被display: none藏着甚至在一个高度为 0 的容器里。visibility走的是is_displayed()要求元素有实际尺寸并且没有被隐藏。要点击的一律用element_to_be_clickable它比visibility还多检查了enabled状态是点击场景下最稳的选择。from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.wait import WebDriverWait wait WebDriverWait(driver, 10, poll_frequency0.2) submit wait.until(EC.element_to_be_clickable((By.ID, submit))) submit.click() wait.until(EC.invisibility_of_element_located((By.CLASS_NAME, loading-mask)))4.3 自定义条件函数的正确写法自带条件不够用时自己写一个。条件函数接收 driver返回真值就表示条件成立until会把返回值原样给你。返回假值就继续轮询。def list_has_items(min_count): def _predicate(driver): items driver.find_elements(By.CSS_SELECTOR, .result-list li) if len(items) min_count: return items # 返回列表本身until 直接把列表给你 return False return _predicate items wait.until(list_has_items(5))这里返回列表是因为列表非空时是真值空列表是假值正好符合until的判断规则。写成闭包的好处是可以带参数比到处写 lambda 可读性好。再举一个等文本变化的例子比text_to_be_present_in_element更严格status wait.until( lambda d: (el : d.find_element(By.ID, status)).text 已完成 and el )这种写法的风险在于find_element抛NoSuchElementException时会被默认忽略列表接住并重试逻辑上是安全的。但如果 lambda 里还做了别的可能抛异常的操作记得把对应异常加进ignored_exceptions。5. 三者混用之后的真实故障一次超时排查5.1 现象等待时间远超设定值这套脚本里implicitly_wait(30)是祖传配置没人敢动。某天加了一条新用例用WebDriverWait(driver, 5).until(...)等一个不一定出现的提示条。设计意图是五秒内出现就处理不出现就跳过。结果实际表现是这一步卡了将近三十五秒才抛异常整个用例超时。5.2 逐步定位的过程第一步是加时间戳确认卡在哪一行确认确实是这个until花了三十多秒。第二步是看这个条件的实现它内部调用了find_element。第三步是临时把implicitly_wait改成 0重跑这一步立刻变成五秒出头抛异常问题定位完成。机制是这样的WebDriverWait的超时检查发生在两次轮询之间也就是说它无法中断正在执行的那一次条件求值。而条件函数内部的find_element又受隐式等待约束元素不存在时它会自己重试三十秒才抛异常。等这个异常冒出来的时候WebDriverWait才拿到控制权去检查时间一看早超了抛TimeoutException。所以实际耗时是隐式等待的三十秒加上零头的轮询开销跟你设置的显式超时五秒毫无关系。这个机制值得记牢因为它意味着显式等待的超时时间在隐式等待面前是无效的只要隐式等待更长。反过来说如果隐式等待只有两秒显式等待设了五秒那最多重试两到三轮就到点了行为符合预期。5.3 结论和推荐配置最后我们的改法是把implicitly_wait从 30 压到 0所有等待走显式等待。改完之后有几条用例失败了因为它们之前是靠隐式等待三十秒硬撑过去的那些点位本来就应该显式声明在等什么。补上显式等待之后整套用例的执行时间还降了。如果你确实想保留隐式等待作为兜底记住两条底线一是值不要设大两到三秒是上限它的定位是给查找动作一点容错,不是帮我把页面等好二是任何显式等待的timeout都必须大于隐式等待的值否则显式等待形同虚设。注意隐式等待和显式等待混用不会报错它会安静地改变你的实际等待时长。这类问题在代码评审里几乎看不出来只能靠时间戳定位。6. 非原生下拉框这类场景的等待组合6.1 为什么 Select 类在这里用不了selenium.webdriver.support.select.Select只认原生标签select和option。现在大量前端组件库用的是div包裹ul包裹li的结构想直接Select(element)会抛UnexpectedTagNameException。这不是 selenium 的缺陷是这类组件本来就没有原生语义只能按普通元素处理。这类组件有几个共同特征正好卡在前面说的几个盲区上外层容器常驻 DOM内层列表默认display: none列表项可能是懒加载的滚动时才追加选中后触发器上的文本会变但 DOM 结构不变。三个特征叠加导致元素都在就是点不到。6.2 从展开到选中的完整等待链路分四步每一步都等一个明确的状态变化不猜时间。第一步等触发器可点击。别用presence用element_to_be_clickable因为触发器在列表展开时可能会被遮一下。第二步点击触发器之后不要立刻找li。等列表真正可见或者更稳一点等列表项数量达标。用visibility_of_element_located有时不够——有些组件在展开动画期间高度是渐变的元素虽然is_displayed()为真但还没稳定。这时候自定义条件更可靠。trigger wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, .select-trigger))) trigger.click() options wait.until(lambda d: ( lis : d.find_elements(By.CSS_SELECTOR, .select-dropdown li) ) and [li for li in lis if li.is_displayed()])第三步点目标项之前先确保它进入可视区域。在滚动容器里的列表项click()有时候会抛元素不可交互先scrollIntoView一次最保险。target next(li for li in options if li.text.strip() 上海) driver.execute_script(arguments[0].scrollIntoView({block: center});, target) wait.until(EC.element_to_be_clickable(target)).click()第四步也是最容易被省略的一步验证结果。点击li之后不要假设选中成功了等一个信号。信号可以是触发器文本变化也可以是某个隐藏域的值变化还可以是li上出现了selected之类的类名。wait.until(lambda d: d.find_element(By.CSS_SELECTOR, .select-trigger).text.strip() 上海)这一步的价值在后面排查问题时会体现出来。如果选中失败而你没有验证错误会延后到提交表单的时候才爆发那时候你面对的是一个空的必填项完全看不出是哪一步出的问题。6.3 列表项懒加载时的特殊处理有些下拉框在几百条数据时会分页加载滚动到底才追加下一批。这种场景下等待条件和前面不同要等的是数量变多而不是存在某个元素。def items_grew(previous_count): def _p(driver): current driver.find_elements(By.CSS_SELECTOR, .select-dropdown li) if len(current) previous_count: return current return False return _p before len(driver.find_elements(By.CSS_SELECTOR, .select-dropdown li)) driver.execute_script( arguments[0].scrollTop arguments[0].scrollHeight, driver.find_element(By.CSS_SELECTOR, .select-dropdown) ) after wait.until(items_grew(before))写这类条件的经验是用相对变化做条件不要用绝对数量。绝对数量写死之后后端多返回一条数据你的用例就挂了。7. 把等待封装成可复用方法的工程做法7.1 页面对象里只存定位元数据写多了以后一定会想到封装这时候有一个决定性的设计选择页面对象类里存WebElement对象还是存(By, value)元组答案是后者而且不能犹豫。WebElement是某个时刻 DOM 的快照引用。页面一旦局部刷新这个引用就失效再用就是StaleElementReferenceException。如果把WebElement塞在页面对象的属性里在你的用例执行期间只要页面刷过一次后面所有方法都不可靠。存(By, value)元组就没这个问题每次用的时候现场查一次拿到的永远是最新的节点。from selenium.webdriver.common.by import By class LoginPage: URL /login USERNAME (By.ID, username) PASSWORD (By.ID, password) SUBMIT (By.CSS_SELECTOR, button[typesubmit]) def __init__(self, driver, timeout10): self.driver driver self.wait WebDriverWait(driver, timeout, poll_frequency0.2) def login(self, user, pwd): self.wait.until(EC.visibility_of_element_located(self.USERNAME)).send_keys(user) self.driver.find_element(*self.PASSWORD).send_keys(pwd) self.wait.until(EC.element_to_be_clickable(self.SUBMIT)).click()这里的写法有个取舍用户名输入框用显式等待密码框直接找因为第一个等待已经保证了整个表单渲染完成。这不是偷懒是省掉重复的等待开销。但要清楚这个推理成立的前提是三个元素在同一个渲染批次里出现如果表单是分段异步加载的就得每个都显式等。7.2 元素枚举和列表操作的封装思路find_elements返回列表这个动作同样需要等待尤其是列表数据来自异步接口的时候。封装一个等到列表至少有一条的方法用起来比每次手写 lambda 省事。def find_all(self, locator, min_count1, timeoutNone): wait self.wait if timeout is None else WebDriverWait(self.driver, timeout) return wait.until( lambda d: (els : d.find_elements(*locator)) if len(els) min_count else False )需要注意find_elements在隐式等待生效时的行为——它超时后返回空列表而不是抛异常所以上面这个 lambda 是安全的不会因为找不到元素而崩掉。如果项目里implicitly_wait设得比较大这里每次轮询都会耗掉那个时长所以我在 5.3 节建议把它压到 0。列表操作的另一个常见需求是按文本找某一项。封装的时候不建议写find_element加文本匹配的 XPath因为文本里带空格、带换行、带不可见字符的情况太多。更稳的做法是拿到列表后在 Python 侧过滤def find_by_text(self, locator, text): els self.find_all(locator) for el in els: if el.text.strip() text.strip(): return el raise TimeoutException(f列表中未找到文本为 {text} 的项)这个写法牺牲了一点性能换来的是可读性和健壮性。列表项在两三百条以内这点开销可以忽略。8. 环境层面的坑也会伪装成等待问题安装环节本身不复杂pip install selenium就够了。现在的版本在驱动管理上比以前省心很多从 4.6 开始内置了自动管理能力会按你本地浏览器版本去匹配对应的驱动不用再手动下载和配置路径。但正因为这层自动化有些问题会以奇怪的形式出现。浏览器自动更新是第一个坑。后台静默升级之后本地驱动和浏览器大版本对不上这时候报的错可能是会话创建失败也可能表现为每次查找都超时。遇到昨天还好好的今天全挂先确认浏览器版本再确认驱动版本别一上来就怀疑等待逻辑。远程执行环境是第二个坑。本地和远端测试机的网络延迟差一个数量级页面里引用的静态资源如果从外网加载首屏时间会明显拉长。这种情况下把显式等待的timeout做成可配置项很实用本地给五秒远端给十五秒通过环境变量或者配置文件注入而不是在代码里写死。第三个坑是多标签页和窗口句柄。新窗口打开是个异步过程等窗口数量的条件和等元素的条件一样重要用number_of_windows_to_be把窗口数等够再切句柄比切过去之后靠元素查找失败重试要快得多。这套东西说到底就是一个思路把等多久换成等什么状态成立。状态是客观的、可验证的时间是主观的、跟着环境漂移的。我最早那批脚本里有一半的sleep现在还在生产里跑着每次环境一变就要手工调那几个数字属于典型的欠债。后来新写的部分统一走显式等待加定位元数据管理一年下来几乎没因为等待问题改过代码。真要说有什么心得就是别在第一次报错的时候随手加 sleep——那一刻偷的懒后面会连本带利地要回来。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →