☰
多级联动下拉框自动化测试:Playwright与Selenium实战解析
2026/10/20 12:22:05 网站建设 项目流程

只要是写过UI自动化的人,十有八九避不开“下拉选择框”这道坎。单层下拉框还好办,原生select有现成API,自定义控件也只要点开再点选项就行。真正让人头皮发麻的,是那种“多级联动下拉”:选了省才加载城市,选完城市再加载区县,每一级都牵扯到异步请求、数据刷新和DOM重绘。这类控件在后台管理系统、电商发布页、配置表单里出现频率极高,而且自动化测试一旦在这里翻车,往往不是简单报错,而是脚本跑着跑着就卡住、误点、选错,最后结果还是绿的,反而更难排查。

这篇文章我想把自己处理多级联动下拉的完整思路和实操过程整理一下。会覆盖从控件结构识别、层级定位策略、异步等待方案,到把整套联动选择封装成可复用组件的完整路径。演示环境用本地模拟页面,工具以 Python + Playwright 为主,Selenium 里的对应写法我也会在关键位置对比说明。不管你是刚入门想找一套能直接用的代码,还是已经遇到过几个诡异问题想看看别人怎么解的,这篇文章里都有对应的内容。

1. 项目背景与需求拆解

1.1 多级联动下拉到底是个什么东西

所谓多级联动,通俗点说就是“选完上一层,下一层才根据上一层的结果重新加载选项”。最常见的就是省市区选择器:你先选省份,城市下拉框才填充该省的城市;选完城市,区县下拉框才填充对应城区的数据。这个交互逻辑本身没什么复杂的,但它天然带来了一个自动化测试最讨厌的东西——对时序的强依赖。

类似的场景远不止省市区。电商后台发布商品时要选三级分类,一级分类决定二级分类,二级分类决定最终的商品属性模板;企业管理系统里选组织架构,部门下面挂子部门;配置规则时选“区域→门店→仓库”,也是同一套玩法。可以说只要系统里有层级化的业务主数据,基本都会做出这种联动下拉框。

单看任何一个下拉框,它无非就是一个“点击展开→出现选项→选中一个”的过程。但把多个这样的过程串起来,并且每一级之间还有网络请求和渲染延迟,问题的复杂度就从“定位元素”变成了“管理状态”。这恰好是UI自动化最容易翻车的地方。

1.2 为什么这类场景这么容易翻车

我在实际项目里总结下来,联动下拉框的坑主要集中在四个方面。

第一个是DOM结构复杂,定位路径非常脆弱。很多现成的下拉组件不是用原生select实现的,而是用input加上一个绝对定位的div弹层模拟的。选项列表可能挂在body下面,也可能挂在某个深层容器里,定位器稍微写深一层,前端一重构就找不到元素了。

第二个是数据异步加载,UI状态不可控。你选择第一级之后,第二级的数据是通过接口动态拉取的。响应快的时候几毫秒,慢的时候可能两三秒,换个网络环境、换台机器,表现完全不一样。如果用固定sleep去等,总有一天会因为你运气不好挂在最慢的那一次上。

第三个是不同框架实现差距巨大。同一套业务界面,有的用Element UI,有的用Ant Design,还有的自己拿div拼了一个。它们虽然交互长得差不多,但DOM结构、事件触发方式、选项渲染时机完全不一样,没有统一API可以用。

第四个是交互细节干扰多。选项弹层出现的时候可能有动画,点击后可能遮罩层还没消失,下拉面板可能触发了懒加载需要滚动才能看到目标选项。这些问题单看都不难,凑到一起就会让脚本表现得很“神经质”,时好时坏。

这四个坑叠加在一起,最典型的翻车场景是:点击第二级选项之后,第三级下拉框还在loading,但脚本不管不顾地直接去点第三级,结果找不到元素,用例失败。改成sleep多等几秒,本地能过,一上CI又挂。本质原因是脚本在对“数据加载完成”这个状态做猜测,而不是做确认。

2. 动手前先看清控件的真实结构

2.1 用开发者工具判断下拉框的真实类型

写自动化脚本前,我强烈建议先按F12打开开发者工具,定位到目标下拉框,仔细观察它的DOM结构。这一步省不得,因为后面所有的定位策略都建立在你对控件类型的判断上。我一般会重点确认四个问题:它是不是原生select、选项是静态存在还是动态生成、展开方式是click还是hover、外层有没有iframe或shadow-root。

根据控件实现方式,可以把常见的下拉框分成几类,处理方式差异很大:

控件类型典型特征推荐定位方式主要坑点
原生select外层标签是select,内部多个option使用框架自带的Select类方法少数框架会动态替换原生控件
自定义下拉用input或div做触发器,点击后弹出ul/div列表先点击触发器,再定位弹出列表里的选项选项可能是点击后才动态渲染的
可搜索下拉带输入框,输入关键字会过滤选项先输入关键词,等结果稳定后再选输入防抖会导致结果延迟更新
多级面板选择时逐级刷新同级面板逐级定位当前面板,不要跨级写路径面板复用导致定位到隐藏面板

判断的方法很简单:直接在开发者工具里看标签结构。存在select+option的就是原生;只有input或div,点击后页面某个地方多出一段列表的,就是自定义下拉。渲染方式也顺便看一眼——如果下拉列表从一开始就在DOM里只是隐藏着,那定位不存在时序问题;如果点击后才把这些元素创建出来,那等待就是必须的。这些信息直接决定脚本怎么写。

2.2 层级定位策略:不写死长路径

很多人在写联动下拉定位的时候,图省事会用一条很长的XPath把从外层容器到目标选项的路径整个串起来。比如//div[@class='cascader']//div[@class='dropdown']//ul/li[1]//span[text()='广东省']这种。这种写法定向非常准,但脆弱到让人抓狂——前端只要改个class名,或者调整一层结构,脚本立刻挂掉。

我现在的习惯是:每一级都只定位两样东西,一个是“触发当前下拉框的元素”,一个是“当前下拉列表容器”。先点击触发元素,等下拉列表出现,然后在列表容器内部找目标文本。每一级的定位互不依赖,前端调整其中某一级时,其他级不受影响。

以省市区为例,第一级和第二级的触发元素、下拉容器都是独立的。脚本每做一次选择,就回到“点击当前级触发器→等列表出现→在列表里选目标项”这个统一动作上来。这样写不仅稳定,还方便后面做代码封装。如果选项在列表里直接排开,就用文本匹配找到一个可见项;如果选项带唯一编码,更建议用data-code这类属性去匹配,这个后面会细说。

2.3 等待策略:跟sleep说再见

联动场景里,等待几乎是脚本的命根子。很多人一开始习惯用time.sleep(2),跑通了觉得挺好。可一旦换环境、换数据量,这个2秒就变成薛定谔的2秒。UI自动化的稳定运行靠的是状态等待,不是时间等待。状态等待的意思是:明确告诉框架,你要等到某个元素出现、某个元素消失、某个条件成立,再继续往下走。

Selenium里常用的做法是WebDriverWait配合expected_conditions。比如等待一个选项真正可点击,用的是element_to_be_clickable,它会同时确认元素可见、位置稳定、没有被遮挡,比单纯判断存在要靠谱得多。Playwright里则提供了wait_for_selector、wait_for_selector(state="hidden")这类API,可以等元素出现,也可以等loading遮罩消失。

联动场景中,我最常用的是两个等待点。第一个是点击触发元素后,等待下拉列表里目标选项可见。第二个是选择完当前级后,等待loading元素消失或下一级选项出现。这两处都等到了,才认为状态真的就绪了,接下来该点就点,该选就选。至于具体等多久,由框架设置的timeout兜底,而不是脚本拍脑袋定一个秒数。

3. 核心实操:完成一整套三级联动选择

3.1 准备一个可控的模拟环境

做这种带联动逻辑的自动化,我从来不建议直接在正式环境上边跑边调试。正式环境的数据不可控,网络状况也不稳定,你压根分不清脚本偶尔挂一次到底是代码问题还是环境问题。更好的做法是先准备一个可控的本地页面,把所有联动的接口响应和DOM结构固定下来。

我这次搭建的模拟页面是一个带三级联动地址选择的表单,结构完全模拟了常见后台管理系统里的那种:三个输入框分别对应省份、城市、区县,点击后弹出选项列表,选择上一级后等待一个模拟接口的延迟,再渲染下一级选项。整个页面很小,跑起来没有任何外部依赖,非常适合用来做自动化脚本的调试基座。

如果你自己也准备起一个类似环境,可以注意两点:延迟不要设成固定值,最好带点随机性,这样才能模拟真实场景里的时序波动;选项数据保留一点重复文本,比如不同层都有“全部”这类选项,用来测试脚本能不能应对文案歧义。我调试的时候就是故意在省市区数据里埋了几个坑,才把后面要讲的问题挨个暴露出来。

3.2 Playwright完整代码:从点到面的三级选择

直接上一套可以跑的Playwright代码。这个函数做的事情很简单:传入页面对象和省市区三个文本值,依次完成选择,全过程带显式等待和最终断言。

from playwright.sync_api import sync_playwright def select_cascader(page, province, city, district): # 第一级:选择省份 page.click("#province-select") province_option = page.locator("#province-dropdown .option-item", has_text=province) province_option.wait_for(state="visible") province_option.click() page.wait_for_selector("#province-loading", state="hidden") # 第二级:选择城市 page.click("#city-select") city_option = page.locator("#city-dropdown .option-item", has_text=city) city_option.wait_for(state="visible") city_option.click() page.wait_for_selector("#city-loading", state="hidden") # 第三级:选择区县 page.click("#district-select") district_option = page.locator("#district-dropdown .option-item", has_text=district) district_option.wait_for(state="visible") district_option.click() # 断言:确认三个输入框的值 assert page.input_value("#province-input") == province assert page.input_value("#city-input") == city assert page.input_value("#district-input") == district if __name__ == "__main__": with sync_playwright() as p: browser = p.chromium.launch(headless=False) page = browser.new_page() page.goto("http://localhost:8080/product") select_cascader(page, "广东省", "深圳市", "南山区") browser.close()

这段代码里没有出现任意一个time.sleep,整体跑下来很快。而且每一步之间都有明确的状态信号:点击选项前等选项可见,选完一级等loading消失。这就是前面提到的“状态等待”的落地写法。

3.3 关键步骤逐步拆解:为什么这么写

真正写惯了UI自动化的朋友一定看得出来,代码里藏着几个刻意为之的细节。

先看定位选项的方式。我这里用的是locator("#province-dropdown .option-item", has_text=province),而不是直接把text=province传进去。原因是很多下拉列表项的DOM结构里,文本并不是直接挂在叶子节点上的,它可能带着前缀图标、编码、角标之类的子元素。has_text是“包含文本”匹配,比text=这种精确匹配要宽容得多,能避免很多因为结构轻微变化导致的找不到元素。

再看到点击前的wait_for(state="visible")。下拉列表是点击触发元素后才动态渲染的,从元素创建到真正被渲染到可点击状态,中间有极短的空窗期。直接click理论上也能碰运气成功,但这份运气不值得赌。先等待可见再点击,相当于把“能否点击”交给了状态判断,而不是交给执行速度。

还有选完一级后的wait_for_selector("#province-loading", state="hidden")。这个等待点极其关键,它确保的是:上一级选择触发的接口请求已经结束、下拉面板的数据已经就绪。正是这个等待,避免了最常见的那种“第二级还在加载,脚本就去点第三级”的翻车。这个等待能够成立的前提,是页面上确实存在一个会短暂出现又消失的loading元素,这也是我建议在写脚本前先做DOM结构摸底的原因。

3.4 再进一步:用接口等待替代UI等待

如果被测系统的接口路径比较稳定,还有一个更高阶的等待方案:直接等待接口响应。这种方案比等UI元素更稳定,因为它是从数据源头确认“数据已经回来了”,而不是靠页面上的某个加载动画消失来间接推断。

Playwright里可以用expect_response包裹点击动作,意思是在点击第一级选项后,同步等待某个接口地址返回成功:

with page.expect_response("**/api/area/children", timeout=15000): province_option.click()

每次点击完一级选项,都会触发一次请求,把上一级选中的编码作为参数传过去,返回下一级可选项。我可以把每一级的点击都包在对应的expect_response里面,这样就不需要依赖loading元素是否存在了。这套方案在接口路径稳定、团队又愿意配合的项目里非常好用,跑起来比UI等待更快,也更不容易受渲染环境波动影响。

但它的缺点也明显:如果接口路径经常变,或者你压根不知道这个联动下拉走的是什么接口,这条路就走不通。所以我的建议是,默认先用UI状态等待,接口等待作为性能更优的备选方案。两种方案可以并存,UI等待保住基本盘,接口等待用来优化稳定性。

3.5 Selenium怎么处理同样的问题

虽然Playwright的API更简洁,但很多存量项目还是在用Selenium,所以我把Selenium里处理同一套联动流程的关键写法也放出来。Selenium本地驱动对动态下拉的处理思路完全一致,只是API不同。

from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC def select_level(driver, trigger_id, dropdown_id, text, loading_id): # 点击当前级触发器 driver.find_element(By.ID, trigger_id).click() # 等待该选项在列表中可点击 option = WebDriverWait(driver, 10).until( EC.element_to_be_clickable( (By.XPATH, f"//*[@id='{dropdown_id}']//*[contains(text(), '{text}')]") ) ) option.click() # 等待loading元素消失 WebDriverWait(driver, 10).until( EC.invisibility_of_element_located((By.ID, loading_id)) ) select_level(driver, "province-select", "province-dropdown", "广东省", "province-loading") select_level(driver, "city-select", "city-dropdown", "深圳市", "city-loading") select_level(driver, "district-select", "district-dropdown", "南山区", "district-loading")

Selenium里最容易写错的是XPath。我见过很多人会写成//li[contains(text(), '广东省')]这样不带任何上下文约束的定位,一旦页面上有其他区域也出现了“广东省”文本,就开始误点。所以我把XPath的根锚定到了当前下拉列表容器的id上,把查找范围限制在列表内部,这个思路和Playwright的#province-dropdown .option-item是一致的。

3.6 断言:确认真的选对了

选完了不代表用例结束了,断言才是UI自动化真正发挥作用的地方。很多人在联动选择之后只会做一个断言:看展示文本对不对。我建议至少再往下追一层,确认表单实际提交的值也对。

第一种是直接读输入框的内容。很多自定义下拉框在选中后会把文本回填到输入框里,所以page.input_value能拿到选中的显示值。

第二种是读关联的隐藏域。有不少组件在选中后会往一个隐藏的input里写入业务编码,比如选择“广东省”后,后台真正要的是它的地区编码。这种隐藏域的value才是业务真正的值,对它做断言比看界面文本更能验证功能正确性。

第三种是看下游联动是否正常。联动选择的意义不只是选择本身,而是影响后续业务。商品分类选完了,页面上的商品属性区是否刷新;地址选完了,配送范围是否跟着变了。如果断言能落到这种业务效果上,用例的价值会高出一个档次。

4. 常见问题与排查技巧实录

4.1 二级选项一直定位不到

这是联动场景里出现频率最高的问题。现象一般是:等了超时时间,某个元素还是找不到,用例直接报NoSuchElement或超时失败。第一反应先别改代码,先截图看现场。截图里如果发现第二级下拉框压根没有弹出,说明问题出在点击第一级后,数据还没加载完成,这时应当检查等待条件是否落在了正确的元素上。

还有一种容易被忽略的情况:选项列表其实渲染出来了,但目标项是滚动加载的,下拉面板里只能看到部分选项,目标项目前根本不在DOM里。这种情况在数据量大的城市列表里尤其常见,直接定位自然失败。解决办法是先把列表滚动到目标选项附近,再定位。Playwright里可以用locator.scroll_into_view_if_needed(),Selenium里可以先找到列表容器再send_keys(Keys.END)。

我的经验里,这类问题十有八九不是定位器写错了,而是脚本没有等到该等的状态,或者列表触发了懒加载。先按这两个方向查,大多数都能快速解决。

4.2 元素存在但点击被拦截

有时候定位器能拿到元素,点击却报了ElementClickInterceptedException,或者点击没有任何反应。这类问题多半是点中了被遮挡的控件:可能是屏幕上有loading遮罩还没消失,可能是下拉动画还没结束,也可能是上一级的选项列表还在那儿没有收起。

解决办法分三步走。第一步,等遮挡元素消失,比如等待loading遮罩隐藏后再尝试点击。第二步,确认动画已经结束,可以用等待元素“稳定”的API,这类API会等元素的位置和大小保持不变再操作,能够避开动画进行中的坑。第三步,把目标元素滚动到视口内再点击,避免它处于可视区域边缘被其他固定元素挡住。

如果这三步都做了仍然被拦截,可以试着用强制点击兜底,Playwright里的.click(force=True),Selenium里用JavaScript直接调用element.click()。但请记住:强制点击是最后手段,它会跳过正常的事件模拟,用多了脚本会越来越不真实。我在本地调试时偶尔会用,但不会把强制点击写进正式用例里。

4.3 文案重复导致选错

下拉选项的文本并不总是唯一的。不同层级的选项里都可能有“全部”,有的列表里还会出现“深圳分公司”和“深圳市”这种包含关系,如果定位只靠文本关键字,很容易点到隔壁项。

解决这个问题最稳妥的方式是优先使用稳定属性。前端在下拉选项上通常会挂业务编码,比如>for attempt in range(2): try: select_cascader(page, "广东省", "深圳市", "南山区") break except TimeoutError: page.screenshot(path=f"cascader_retry_{attempt}.png") page.reload() else: raise AssertionError("两次尝试后仍未完成联动选择")

这层重试看着简单,但它挡住了很多因为CI节点性能波动导致的“假失败”,实际使用效果非常明显。

5. 封装、数据驱动与长期维护

5.1 把联动选择封装成通用函数

省市区三级联动写完之后,你会发现项目中还有其他类似控件,比如商品分类三级选择、组织架构多级选择。它们的交互模式几乎一样,只是触发器、列表容器和层级数量不同。如果每个页面都复制一份代码,后续维护会非常痛苦,所以最好封装成一个通用函数。

def select_cascader(page, triggers, dropdowns, items, loading_selectors=None): """ triggers: 每级触发器定位器列表 dropdowns: 每级下拉列表容器定位器列表 items: 每级要选中的文本列表 loading_selectors: 每级loading元素定位器列表(可选) """ for idx, item in enumerate(items): page.click(triggers[idx]) option = page.locator(dropdowns[idx]).filter(has_text=item).first option.wait_for(state="visible") option.click() if loading_selectors and idx < len(items) - 1: page.wait_for_selector(loading_selectors[idx], state="hidden")

这个函数接受三份配置,选择几级、每级点什么、每级点完等什么,全部由参数决定。换一个联动控件时,脚本只需要提供不同的定位器列表和选项列表,函数本身一行都不用动。我实际用下来,这类封装大概能让单条用例的维护成本下降一半以上。

5.2 数据驱动:把测试数据放到配置文件

联动选择脚本里最容易出问题的其实是测试数据本身。我早期写死过几个省市组合,后来测试环境数据被清掉重灌,导致脚本大面积变红。后来我把所有联动用例的测试数据都挪到了配置文件里,脚本只负责根据数据执行,不再关心数据长什么样。

用JSON或者YAML来存数据都行,结构很简单:一个用例名对应一组省市区文本。跑用例的时候从配置里读取,再传给封装好的联动函数。这套方式的优点很直接,测试数据变更时不需要改代码,数据维护也完全不需要懂自动化的人参与。

{ "guangdong_shenzhen": { "province": "广东省", "city": "深圳市", "district": "南山区" }, "shanghai_huangpu": { "province": "上海市", "city": "上海市", "district": "黄浦区" } }

数据驱动带来的另一个好处是执行并行化。多组数据可以拆到多个用例里跑,而不是在一个用例里把所有组合都选一遍,这样哪怕其中一组数据挂掉,也不影响其他组合的执行结果。

5.3 从定位技巧走向协作约定

写联动下拉的自动化做得越久,我越觉得一个优秀的自动化不是纯靠技术堆出来的,而是需要和前端团队达成一些约定。如果项目里用了第三方组件,比如现成的级联选择器,它的DOM结构往往由组件库控制,页面开发者也很难修改,这时候遇到不定制就定位不了的情况,光是发愁没意义。

最有效的途径是前端在关键选项上加上稳定的data属性,比如data-code、data-level,让自动化和前端在“身份标识”这件事上达成共识。前端加几个属性可能只要几分钟,自动化这边却可以省下几周的维护时间。这个思路在我处理多个联动组件之后被反复验证,算是性价比最高的一条建议。

另外,如果系统允许,尽量在用例设计上减少对UI联动的依赖。有些数据完全可以通过接口预置,让页面直接处于“已经选好”的状态,自动化只需要验证后续流程。这样可以把UI自动化留到真正需要验证页面交互的地方,避免把宝贵的时间花在一遍遍点同样的下拉框上。

最后再说点实在体会。做这类联动选择的自动化,最大的感知是:问题很少出在“不会选”,而是出在“不知道什么时候能选”。大部分翻车都是时序问题,不是定位问题。所以整个脚本设计的核心不是选择动作本身,而是对每一级状态的等待和确认。宁可多写一个等待,也不要为了跑得快省掉一个,这在稳定性上的收益远比表面上看起来大得多。

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

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

立即咨询