
我曾经接过一个支付类后台的回归测试优化任务。套件里有 60 条用例跑完要一个小时出头其中大量时间花在界面上转圈、按钮半天不亮、弹窗迟迟不弹这些无谓等待上。把耗时明细打出来以后发现Selenium 的WebDriverWait占了差不多四成执行时间而且大多数等待并不是真的在等业务而是在等一个早就该满足的前置条件被轮询命中。这篇文章就从显式等待为什么这么贵讲起把超时、轮询、等待条件、用例结构这几层逐个拆开最后给出一套我实际验证过的改造方法和前后数据。很多人一提到 Selenium 性能优化第一反应是把显式等待时间调短一点。但真正的问题是你根本不知道这 10 秒里测试是在等元素还是在等轮询周期或者在等一个永远不会到来的超时。只看表层的 timeout 数字优化方向很容易走偏。我希望这篇内容能帮你看清显式等待的时间构成知道哪些等待可以删、哪些等待该缩短、哪些等待必须重构然后真正把测试时间压下来。1. 显式等待的时间账一个 10 秒等待在回归测试里的真实开销1.1 一个等待指令背后做了什么先看一段最常见的写法from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.ID, submit-btn)) )这段代码的意思是最多等 10 秒每 0.5 秒去看一次页面里有没有出现submit-btn。如果元素在第 1 秒就出现了那么实际耗时约 1 秒到 1.5 秒之间因为 0.5 秒轮询一次第一次在第 0.5 秒检查完没有第二次在第 1 秒检查到有实际返回时间取决于轮询命中点。如果元素在第 9.9 秒才出现就等了整整 9.9 秒如果元素根本没出现就实打实把 10 秒全部耗尽。这就是显式等待的第一个昂贵之处它按最坏情况付费。你以为自己只是写了最多 10 秒但在测试未做充分容错时所有异常路径都按最高价结算。1.2 一条用例里到底堆了多少元等待更麻烦的是显式等待常常不是在一条用例里只出现一次而是层层叠加打开页面后等某个菜单项出现点击菜单后等列表容器加载选中一行后等编辑按钮可点击提交后等提示信息出现再等提示信息消失才进行下一步断言。这五个等待如果每个都设置 10 秒即使每一步都顺风顺水每个等待平均花掉 1.5 秒单条用例也要额外付出七八秒。如果有一两个步骤出现抖动等满 10 秒的情况立刻出现一条用例就可能从 30 秒膨胀到 60 秒。我统计过某个后台项目的真实耗时分布60 条用例的原始执行时间约 65 分钟其中显式等待相关的累计时间大约 27 分钟占 41.5%。等我把这些等待点逐个列出来发现其中将近一半是因为元素定位条件写得太宽泛或者选错了等待条件导致条件明明已经满足却因为轮询间隔错过了最新状态或者干脆在等待一个根本不必要的元素。1.3 先给等待成本建立一个量化的概念场景单次等待设置实际平均耗时说明元素本来就在条件瞬间满足10 秒0.5 ~ 1 秒轮询周期导致的最短等待元素在 1.2 秒后出现10 秒1.5 秒0.5 秒轮询需经过 3 次检查元素在 8.1 秒后出现10 秒8.5 秒大量时间浪费在网络或后端处理慢元素一直未出现10 秒10 秒超时是最大支出条件在第 0.1 秒已满足轮询却在 0.6 秒才检查10 秒0.6 秒轮询间隔吞掉的边际时间这张表里最容易被忽视的就是第一行。元素明明已经在页面上你却因为轮询间隔而付出 0.5 到 1 秒。如果一条用例有十个这种无用等待就等于凭空多出 5 到 10 秒。所以减少显式等待时间最直接的一步不是把 timeout 改小而是先消灭那些根本不需要等待的等待。2. 超时与轮询间隔两个改变等待总耗时的精确旋钮2.1 timeout 不是越大越安全WebDriverWait(driver, timeout)里的 timeout 决定的是最坏情况不是平均情况。很多测试人员习惯性地写 15 秒甚至 20 秒理由是项目环境慢等待给足一点防止误报。但在实践里这个习惯往往掩盖了真实的页面性能问题也让测试执行时间变得不可控。合理的超时设置应该根据数据来源分层本地测试环境、前端资源加载快超时建议 5 秒左右联调环境或测试服务器负载高给到 8 秒到 10 秒涉及真实第三方支付、短信等外部依赖时允许单独场景给 15 秒但要用单独的等待条件去约束不要全局统一。我在改造中把全局的 10 秒超时降到 6 秒配合更精确的等待条件之后没有出现原本担心的稳定性下降。因为大多数页面元素在 1 到 3 秒内就会出现真正需要 10 秒以上的场景往往意味着前一步操作没有生效等待再久也没有意义。2.2 poll_frequency 是如何暗中吃掉时间的Selenium 的WebDriverWait默认每 0.5 秒执行一次条件检查。很多人没有意识到这个参数对等待总耗时的影响是双向的条件在第 0.1 秒就满足了你要到 0.5 秒才被发现 条件在第 0.6 秒满足你要到 1.0 秒才发现。如果页面更新频繁、条件状态变化快把轮询间隔从 0.5 秒降到 0.2 秒甚至 0.1 秒可以显著缩小条件已满足但未被发现的窗口。WebDriverWait(driver, timeout6, poll_frequency0.2).until( EC.element_to_be_clickable((By.CSS_SELECTOR, [data-testidsave])) )但要注意轮询频率提高之后每次条件检查都会执行一次 DOM 查询页面元素过多时反而增加浏览器与驱动之间的通信次数。所以不能盲目压到 0.05 秒。综合来看0.2 秒是一个比较平衡的值既能在大多数交互场景里及时感知状态变化又不会因为高频轮询导致驱动调用量爆炸。2.3 隐式等待和显式等待混用的严重时间损耗这部分必须单独提醒。Selenium 的implicitly_wait和WebDriverWait如果同时存在驱动程序在每次find_element时都会先走一遍隐式等待逻辑而WebDriverWait内部的轮询又会对元素进行多次查找每次查找都可能触发隐式等待的等待周期。两者叠加后轮询间隔会从你设定的 0.2 秒被拉长成一个未知值等待的总时间可能远超表面配置。我在某个项目里看到过这种配置driver.implicitly_wait(10)同时每一处交互都写了WebDriverWait(driver, 10)结果单条用例里的等待耗时比单独使用任何一种都要翻倍。改造时不光要把显式等待调优还必须把隐式等待置为 0只保留局部的显式等待才能让轮询间隔真正生效。driver webdriver.Chrome() driver.implicitly_wait(0) # 关闭隐式等待避免与显式等待的轮询叠加3. 用更精准的条件替代更长的等待expected_conditions 的实战选择3.1 presence、visibility、clickable 的差别远超你的直觉显式等待的条件选择直接决定了需要等多久。拿元素出现这件事举例presence_of_element_located只检查元素是否出现在 DOM 树中哪怕它被遮挡、不可见、不可点击也会判定为满足visibility_of_element_located要求元素不仅存在于 DOM还要可见且尺寸大于 0element_to_be_clickable则在可见的基础上检查元素是否可点击包括是否被其他元素遮挡。很多人在写点击前的等待时统一用presence_of_element_located结果就是元素在 DOM 里出现了但它还在加载动画下面脚本直接去点击事件没生效后续断言失败于是继续加等待时间。这属于典型的条件没选对靠延长等待来补偿。正确的做法是把条件和后续动作绑定。要点击按钮就等element_to_be_clickable要读取文本就等visibility_of_element_located要判断路由是否切换就等 URL 或标题变化。这样才能让等待条件紧贴真实业务状态减少等到了但用不了的二次等待。3.2 用自定义条件把等待时间压缩到事件真正完成的瞬间expected_conditions内置的条件远远不够应付所有场景。比如一个异步表格加载成功后某列会变成已完成文案你需要等文本变为特定值后再进行下一步。用内置条件可以拼text_to_be_present_in_element但有些动态页面会先出现旧文案再刷新简单条件经常误判。这种情况下我建议写一个自定义等待函数把判断逻辑收敛到一处。from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By def wait_for_text_element(driver, locator, expected_text, timeout6): def _check(driver_instance): try: element driver_instance.find_element(*locator) if element.is_displayed(): return element.text.strip() expected_text except Exception: return False return False WebDriverWait(driver, timeout, poll_frequency0.2).until(_check)这种写法最大的好处是条件内部已经排除了元素不存在、不可见、文本为空等分支等待只会在文本变为目标值的那个瞬间结束不会因为中间态反复轮询。3.3 一个常见的病根等待消失比等待出现花费更久显式等待还有一个隐蔽的高消耗点就是等待元素消失。比如点击保存后页面弹出一个 loading 遮罩层要等它消失才能继续。很多人会写成WebDriverWait(driver, 10).until_not( EC.presence_of_element_located((By.CLASS_NAME, loading)) )问题在于until_not配合presence_of_element_located只要 loading 元素在 DOM 中存在就会一直等到超时。而很多框架在 loading 结束时只是把它隐藏了并没有移除 DOM 节点。这个等待就会次次跑满 10 秒。这时候应该把条件换成不可见而不是不存在WebDriverWait(driver, 10).until_not( EC.visibility_of_element_located((By.CLASS_NAME, loading)) )这个改动我在项目中调整过不少处每一处都能去掉七八秒的无效等待。4. 减少等待次数从用例结构上摆脱对显式等待的依赖4.1 把等待每个元素改成等待一个完整状态显式等待用得多的用例通常有一个共同特征每做一个动作就停下来等一个中间元素。这种写法非常浪费。因为页面 A 里的中间元素出现不代表页面 A 已经稳定你等到它出现之后往往还要等下一个元素。更好的思路是从用例层面找到一个状态锚点。比如进入列表页后等待表格容器可见且数据行数大于 0这一条等待可以代替页面加载后对菜单、面包屑、搜索框、表格四个元素的逐个等待。def wait_for_table_rows(driver, min_rows1, timeout6): def _check(driver_instance): rows driver_instance.find_elements(By.CSS_SELECTOR, tbody tr) return len(rows) min_rows WebDriverWait(driver, timeout, poll_frequency0.2).until(_check)等待执行完后意味着页面核心内容已经渲染完成。此时再去操作搜索框、翻页按钮基本都是立即可用的不必再为它们单独等待。这是减少显式等待总时间的第二个关键维度不是把所有等待时间调小而是让等待点变少。一个用例从五个等待变成两个等待即使平均等待时间不变总耗时也直接砍掉一半还多。4.2 用数据状态代替界面状态有些等待的本质是在等后端接口返回。前端交互上有按钮置灰、loading 转圈、模态框弹起这些表现但你真正关心的是数据是否写进了后端。与其在前端轮询按钮状态不如充分利用响应式页面里的反馈信号。最典型的场景是列表刷新。新增一条数据后列表会异步重新请求接口。测试如果只等保存按钮恢复可点击很可能保存请求还在处理列表就已经在刷新了此时断言新增数据就会落空。更好的等待条件是同时等待列表中出现新数据主键对应的行WebDriverWait(driver, 6, poll_frequency0.2).until( EC.presence_of_element_located((By.XPATH, //tr[.//text()[contains(., 单号-20240901)]])) )这样等待结束的时机基本就等于数据真正渲染完成的时机比等按钮、等提示框要精准得多。一个等待点完成了多个目的后面就不需要再补第二个等待。4.3 等待应该有边界而不是无限兜底显式等待设计得再合理也不应该把整个用例的容错全部押在等待上。如果某些前置动作频繁失败等待条件就会频繁跑满超时优化延迟也无济于事。更稳妥的做法是增加前置条件断言一旦页面状态不符合预期立刻失败而不是让后续等待白白消耗超时窗口。我习惯在每个关键动作之前加一个轻量级的状态护栏比如判断元素是否is_enabled()或者检查当前 URL 是否包含预期路径。这样用例进入等待循环之前就能快速排除明显不满足前置条件的场景。显式等待只用来等待那些真正存在延迟的动作而不是为所有动作的失败兜底。5. 一次真实改造显式等待占比从 41% 降到 12% 的操作复盘5.1 改造前的诊断步骤我负责的那套测试用例原始执行时间约 65 分钟。诊断分三步走第一步在用例中记录每次WebDriverWait的开始时间和结束时间把等待耗时单独统计到一个日志文件里。这个步骤不需要什么复杂框架直接写一个简单的装饰器就能实现。import time import logging def timed_wait(wait_func): def wrapper(*args, **kwargs): start time.time() result wait_func(*args, **kwargs) cost time.time() - start if cost 1: logging.info(wait cost %.2fs | condition%s, cost, wait_func.__qualname__) return result return wrapper第二步按耗时从高到低列出所有等待点找出哪些等待经常超过 3 秒。第三步逐一分析这些长期等待是因为页面真的慢还是等待条件选错或者轮询间隔太大。最终定位到了几类主要问题loading 遮罩层消失等待次次跑满 10 秒点击按钮前用了过于宽泛的 presence 条件全局隐式等待和显式等待叠加导致轮询被拉长每条用例中间的冗余等待点偏多。5.2 逐项改造与数据对比改造动作分成了五块timeout从 10 秒降到 6 秒poll_frequency从默认 0.5 秒调到 0.2 秒关闭全局隐式等待driver.implicitly_wait(0)把所有等元素出现后再操作改成等元素可点击后再操作把每个用例中等待中间态元素的点合并为等待最终业务状态。改造后同一套用例的执行时间从 65 分钟降到 36 分钟显式等待累计时间从 27 分钟降到 4 分多钟占比从 41.5% 降到 12% 左右。最快见效的是 loading 遮罩层那类问题光是修改等待消失的条件就省下了将近一半的等待时间。指标改造前改造后总执行时间约 65 分钟约 36 分钟显式等待累计时间约 27 分钟约 4.5 分钟显式等待占执行时间比41.5%12%因等待导致的用例失败率约 3%约 0.8%5.3 优化之后不等于可以无限压缩警惕两个极端改造完成后我又做了一轮回归确认把超时从 6 秒继续压到 4 秒、轮询从 0.2 秒压到 0.1 秒时测试总时间进一步下降但用例失败率开始上升。原因是某些外部接口在测试环境响应确实慢4 秒的超时不足以覆盖正常波动属于过度优化。所以在调整参数时我的经验是遵循一个原则先优化等待条件和数量再优化时间参数。条件不对参数再小也没用条件对了参数稍微保守一点也不会明显拖慢整体。最后留一个不算极端的余量比如 6 秒既保证了正常环境的稳定又不会成为性能包袱。另外还要提醒一点显式等待优化不是一次性工作。页面上稍微改个 DOM 结构、换一套第三方组件原来的等待条件可能就会失效。每半个月对等待耗时日志做一次回顾把耗时超过 5 秒的等待点重新排查一遍这套优化成果才不会慢慢回退。以我自己的体感来说做 Selenium 性能优化最值回票价的不是盯着 timeout 数字反复改而是把为什么要等、等的是什么条件、有没有更短的路径这三个问题想清楚。显式等待本身不是坏东西真正浪费时间的是那些等错对象、等错条件、等完了也没用的等待。把这一层理顺测试跑得快就是顺带的事。