先说个结论:ActionChains 是 Selenium 里被低估得最严重的一个类。简单点说,它是用来把“一组连续的用户操作”像排练节目一样先编排好,再一次性真正执行到浏览器里的。拖拽、悬浮、组合键、按住移动、滚轮,这些单靠click()或者send_keys()搞不定的事情,到了ActionChains这里才算是有了正解。
用 Selenium 做自动化测试的朋友基本都会碰到这样的困境:页面元素定位得到,但业务动作模拟不出来。比如排序列表要鼠标拖拽,导航菜单要悬浮才能展开,长页面要模拟人工滚动阅读,富文本编辑器要做 Ctrl+A 全选再删除。这些场景看似零散,底层其实都在调用同一套机制——鼠标和键盘事件的“有序组合”。ActionChains 就是这个机制最标准的出口。
这篇文章我会从事件原理、API 细节、完整实战案例和踩坑记录四个角度,把这套玩法拆开揉碎讲清楚。适合已经开始用 Selenium 做自动化、但对 ActionChains 还停留在“知道有这玩意儿”阶段的同学,也适合准备系统梳理自己测试框架的测试开发。
1. 动作链的核心机制:它更像“排练”而不是“口令”
1.1 所有操作先排队,perform 才是真正开演
不少新手第一次写 ActionChains 都会犯同一个错:写了.move_to_element().click(),然后以为已经点了,结果页面纹丝不动。
这里最关键的是理解它的执行模型。ActionChains内部维护着一个“待办事件列表”,你写的每一次动作,比如move_to_element、click、key_down,都只是往队列里追加一个指令,所有指令会按顺序暂存起来。直到你调用.perform()方法,Selenium 才会把队列里积压的动作按照顺序真正发送给浏览器执行。
这个过程很像舞台剧的彩排:先走位、对台词、练动作,最后才正式演给观众看。如果你没喊“开始”,那前面彩排的内容等于白做。
# 错误示范:以为执行了,其实只做了编排 from selenium.webdriver.common.action_chains import ActionChains ActionChains(driver).move_to_element(menu).click(sub_menu) # 正确姿势:补上 perform ActionChains(driver).move_to_element(menu).click(sub_menu).perform()这样设计的原因其实很合理。复杂的人类操作动作之间往往有依赖关系,比如拖拽必须“按下 -> 移动 -> 抬起”三步连在一起,如果每步都即时执行,中间任何一步出错就很难回滚。先排队再统一执行,既保证动作顺序,也方便你像流水线一样自由组合。
1.2 它内部维护了一个“按键和鼠标状态机”
很多人以为 ActionChains 只是把动作简单拼在一起,其实它内部更像一个状态机:会持续跟踪当前鼠标是“按下”还是“抬起”,当前键盘上哪些修饰键(Ctrl、Shift、Alt)被按住。
这个状态机是整个高级用法的基石。举个例子,你调用click_and_hold(element)之后,鼠标就进入了“按住”状态,接下来同一个链里的move_to_element(target)会被解释成“按住鼠标的情况下移动到目标”,最后的release()才把鼠标松开。三个阶段合起来,就是一次完整的拖拽动作。
修饰键也是同理。key_down(Keys.SHIFT)会记录“Shift 键被按下”,后续的send_keys('abc')就会把字母变成大写,直到你调用key_up(Keys.SHIFT)释放它。如果链中间断开了,或者你拆成了两个独立的 perform,那状态就可能会丢失,这也是很多诡异 bug 的根源。
1.3 常用 API 全景速查
先给一张总表,后面再逐个讲细节。这张表我建议收藏,排查问题的时候非常管用:
| 方法 | 用途 | 常见参数 |
|---|---|---|
move_to_element(element) | 把鼠标移动到元素中心位置 | WebElement |
move_to_element_with_offset(element, x, y) | 移动到元素相对指定偏移量的位置 | WebElement, int, int |
move_by_offset(x, y) | 相对当前鼠标位置移动偏移量 | int, int |
click(element=None) | 单击鼠标,不传参则点击当前位置 | WebElement(可选) |
double_click(element=None) | 双击元素 | WebElement(可选) |
context_click(element=None) | 右键点击 | WebElement(可选) |
click_and_hold(element=None) | 按住鼠标不松开 | WebElement(可选) |
release(element=None) | 松开鼠标左键 | WebElement(可选) |
drag_and_drop(source, target) | 拖拽到目标元素 | WebElement, WebElement |
drag_and_drop_by_offset(source, x, y) | 拖拽到指定偏移坐标 | WebElement, int, int |
key_down(key) | 按下一个键盘键不松开 | Keys 枚举值 |
key_up(key) | 松开键盘键 | Keys 枚举值 |
send_keys(*keys) | 向当前焦点元素发送按键或文本 | 字符串或 Keys 值 |
pause(seconds) | 暂停执行一段秒数 | float |
scroll_by_amount(delta_x, delta_y) | 按像素值滚动(Selenium 4.2+) | int, int |
scroll_to_element(element) | 滚动到元素可见位置(Selenium 4.5+) | WebElement |
perform() | 执行队列中的所有动作 | 无 |
reset_actions() | 清空所有待执行动作和状态 | 无 |
这些 API 看起来都是“点一下”“动一下”,但组合起来能覆盖几乎所有人工操作的模拟。接下来我挑几个最容易出问题的深入讲。
2. 高级 API 的细节,练熟这些才敢说会用
2.1 鼠标类动作:悬浮、拖拽、右键的底层逻辑
先说move_to_element,它是悬浮菜单、tooltip 这类场景的核心。它会把鼠标移动到元素的中心点,这一点很多资料没提。如果你的元素是一个超长列表项,想移到它的右上角怎么办?用move_to_element_with_offset(element, x, y),这里的x和y是相对于元素左上角的偏移,并不是屏幕坐标。
拖拽则是整个鼠标动作里最讲究配合的。它的底层思路不复杂:把鼠标移动到源码位置,按下左键不松,移动到目标位置,松开左键。
source = driver.find_element(By.ID, "item-1") target = driver.find_element(By.ID, "slot-2") ActionChains(driver) \ .move_to_element(source) \ .click_and_hold() \ .move_to_element(target) \ .release() \ .perform()这里有个细节极易出错:click_and_hold()不传参数时,会在鼠标当前停留的位置按下。所以你如果先做了move_to_element(source),那按下位置就是source没问题。但如果直接调用click_and_hold(source),效果也等价,只是可读性差一点。
右键context_click和双击double_click相对简单,但有个共同坑点:它们都依赖“当前焦点元素”。操作前最好先用.click(element)或.move_to_element(element)把焦点带过去,否则可能在空白处触发。
2.2 键盘类组合动作:KeyDown 才能真正模拟“按住”
键盘动作很多人会直接用send_keys,但组合键必须用key_down和key_up包裹,否则无法表达“同时按住多个键”的状态。典型的全选删除操作:
from selenium.webdriver.common.keys import Keys editor = driver.find_element(By.ID, "editor") ActionChains(driver) \ .click(editor) \ .key_down(Keys.CONTROL) \ .send_keys('a') \ .key_up(Keys.CONTROL) \ .send_keys(Keys.DELETE) \ .perform()这里send_keys('a')是在 Ctrl 被按下的前提下发送的,所以它等效于 Ctrl+A。要注意key_up(Keys.CONTROL)不能省略,否则整个链执行完,浏览器里 Ctrl 键还处于“被按住”的逻辑状态,后续用户/脚本的操作就会变得诡异。
还有一个跨平台问题特别值得注意:Windows 和 Linux 上组合键修饰键大多是Keys.CONTROL,但 macOS 上通常应该是Keys.COMMAND。如果你维护的测试用例要跑多个平台,尽量不要在代码里硬编码,而是根据sys.platform或driver.capabilities['platformName']动态选择。
import sys MODIFIER = Keys.COMMAND if sys.platform == 'darwin' else Keys.CONTROL2.3 滚轮、水平滚动条与移动端滑动
这是和热搜词“网页左右滑动”“左右滚动可见”最相关的一块,也是很多 Selenium 版本差异比较大的地方。早期 Selenium 想模拟鼠标滚轮,只能通过send_keys(Keys.PAGE_DOWN)或者执行 JS 脚本。到了 Selenium 4.2+,ActionChains终于原生支持了scroll_by_amount。
先看普通纵向滚动:
# 向下滚动 500 像素 ActionChains(driver).scroll_by_amount(0, 500).perform() # 向上滚动 200 像素(负值表示向上) ActionChains(driver).scroll_by_amount(0, -200).perform()水平滚动条则对应delta_x参数。有些页面内容超宽,横向滚动条被隐藏在不显眼的位置,用 JSscrollIntoView又不一定好用,水平方向的scroll_by_amount就能派上大用场:
# 页面内容向左滚动 300 像素,露出右侧隐藏部分 ActionChains(driver).scroll_by_amount(-300, 0).perform()这个方法的语义要理解清楚:delta_x是水平方向滚动量,正数代表向右滚动,负数代表向左滚动,方向和我们“刷手机”的惯性方向正好相反,初次接触很容易把 300 和 -300 写反。
再扩展一下移动端场景。做 App 自动化时,如果你用的是 Appium,在MobileBy下模拟滑动一般会用TouchAction或W3CActions。但如果你是在浏览器里做响应式页面测试,也可以用 ActionChains 模拟触摸滑动:
# 模拟在屏幕某个区域从右向左快速滑动 from selenium.webdriver.common.actions.pointer_input import PointerInput from selenium.webdriver.common.actions import interaction actions = ActionChains(driver) wheel = actions.w3c_actions # 按下 -> 向左移动 -> 松开,类似滑动手势 wheel.pointer_action \ .move_to_location(800, 600) \ .pointer_down() \ .move_by(600, 0) \ .pointer_up() wheel.perform()这种用法的门槛稍高,但理解了“鼠标事件 + 偏移量”就能看懂。
2.4 时机控制:pause 是稳定性的第一个朋友
ActionChains 默认的动作执行速度非常快,几乎是一瞬间把所有事件全部发完。但真实用户操作不可能这么快,很多页面在连贯事件之间还需要时间处理动画、发起异步请求或者渲染新元素。
这时候pause(seconds)就是稳定性的关键。它比time.sleep()更优雅,因为它插在链条内部,只暂停当前这条动作链的执行,不会影响后续其他 WebDriver 命令。
ActionChains(driver) \ .move_to_element(menu) \ .pause(0.5) \ .click(item) \ .perform()上面这个例子里,悬浮菜单展开动画需要几百毫秒,如果鼠标刚移过去就立刻点击,菜单可能只展开了一半,导致点击落在空白区域。加一个 0.5 秒的 pause,点击成功率立刻提升一个档次。
这里我自己的经验是:pause 的时间不宜写死太大,否则整个用例会变得很慢。推荐的组合套路是“小 pause + 显式等待”,anchor 动作先用 pause 保证动作连续性,后续页面状态的等待用WebDriverWait配合预期条件来处理。
3. 实战案例:从拖拽排序到悬浮菜单,五个可以直接抄的代码
3.1 案例一:拖拽排序列表
后台管理系统里“拖拽排序”是高频功能,比如配置菜单顺序、调整运营位优先级。这类功能的手工测试麻烦,自动化如果不会 ActionChains 就更麻烦。
我用一个常见的前端组件举例,它把排序项渲染成一列列表,支持拖到目标位置调序:
from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.common.action_chains import ActionChains driver = webdriver.Chrome() driver.get("https://your-test-page.com/sort-list") driver.implicitly_wait(5) # 把第 2 项拖到第 4 项的位置 source = driver.find_element(By.XPATH, "//ul[@id='sortList']/li[2]") target = driver.find_element(By.XPATH, "//ul[@id='sortList']/li[4]") ActionChains(driver) \ .move_to_element(source) \ .click_and_hold() \ .move_to_element(target) \ .release() \ .perform() # 断言顺序已经变化 items_after = driver.find_elements(By.XPATH, "//ul[@id='sortList']/li") assert items_after[3].text == "原本第2项的名称"这个案例的关键在于click_and_hold()和release()前后必须通过move_to_element把鼠标位置带到位。如果省略了中间的move_to_element(target),鼠标会在原位置直接松开,拖拽会变成“按了一下”而不是“拖动”。
3.2 案例二:悬浮展开二级菜单
电商、后台管理里最常见的导航交互就是 hover 出子菜单。直接.click是不行的,因为子菜单的 DOM 在悬浮事件触发前根本不存在或不可见。
main_menu = driver.find_element(By.ID, "main-menu") sub_option = driver.find_element(By.XPATH, "//div[@class='sub-menu']//a[text()='用户管理']") ActionChains(driver) \ .move_to_element(main_menu) \ .pause(0.6) \ .click(sub_option) \ .perform()这里的pause(0.6)是我针对某个具体项目调出来的经验值:菜单展开动画是 400ms,我留了 200ms 余量。如果你发现点击时偶尔失灵,优先把 pause 时间调大,其次检查子菜单是否真的在父菜单的 hover 事件后才渲染。
3.3 案例三:模拟人工滚轮阅读,触发懒加载
很多信息流页面是懒加载的,一次全量滚动到底部也不一定能把所有内容都加载出来,反而可能触发风控。更稳妥的做法是分段滚动,每滚一段停一下,模拟一个真实用户往下读的节奏。
for _ in range(5): ActionChains(driver).scroll_by_amount(0, 300).perform() time.sleep(0.4) # 滚动完成后,找到某个懒加载出来的元素并断言存在 driver.find_element(By.XPATH, "//div[@data-loaded='true']")把scroll_by_amount和time.sleep结合,信息流数据基本都能稳定加载出来。这里我刻意没有在 ActionChains 内部用pause,是因为“分段滚动”本身更关心两次滚动命令之间的间隔,而不是链条内部的连贯性,两种写法在效果上差别不大,但拆出来可读性更高。
3.4 案例四:iframe 里的拖拽,切换上下文是前置条件
iframe 是 ActionChains 的老大难问题。如果你在拖拽过程中跨越了 iframe 边界,必须先切换到 iframe 内部,否则元素对象虽然找得到,但 WebDriver 的事件坐标映射会错位甚至报错。
frame = driver.find_element(By.ID, "editor-frame") driver.switch_to.frame(frame) source = driver.find_element(By.ID, "block-a") target = driver.find_element(By.ID, "block-b") ActionChains(driver) \ .drag_and_drop(source, target) \ .perform() # 操作完记得切回默认上下文 driver.switch_to.default_content()这个案例里最容易踩的坑是:你先在主页面上定位了 iframe 内的 source 元素,然后切换进 iframe,结果 source 的引用还能用,但目标元素是在 iframe 外框上的,你再在 iframe 上下文里定位它就会定位失败。先理清“元素属于哪个上下文”再开始编排动作链。
3.5 案例五:把隐藏的水平滚动条“抠出来”
之前聊过scroll_by_amount可以做水平滚动,但某些浏览器的滚动条在没有内容溢出时根本不显示。判断页面上某个横向滚动条到底存不存在,可以先获取元素的scrollWidth和clientWidth对比:
box = driver.find_element(By.CLASS_NAME, "horizontal-scroll-box") scroll_width = driver.execute_script("return arguments[0].scrollWidth;", box) client_width = box.size["width"] if scroll_width > client_width: # 存在水平溢出,向左滚动 200 像素,让右侧内容可见 ActionChains(driver).scroll_by_amount(-200, 0).perform() else: print("没有水平溢出,无需滚动")这个检查思路在移动端页面适配测试里特别实用,能快速判断“横向滚动可见性”是否符合预期。配合断言,你可以直接在用例里验证某个关键按钮是否滚动后可见、可点击。
4. 常见问题与排查技巧实录
4.1 链没断、事件也发了,页面就是没反应
这可能是 ActionChains 高频问题 TOP1。我的排查顺序一般是这样:
- 第一步,确认
.perform()真的被调用了。很多人贴代码时省略了它,导致大家复制下来直接不生效。 - 第二步,确认目标元素处于可交互状态。比如被遮罩层盖住、
disabled属性没有移除、或者display:none,动作链事件发出去了,但浏览器命中测试落在了别的元素上。 - 第三步,确认浏览器窗口处于激活状态。如果你的测试机在跑用例时被切到别的窗口,或者浏览器窗口最小化,某些浏览器会拒绝合成鼠标事件。
经验做法是:在动作链执行前,先用WebDriverWait等元素可见、可用,然后判断窗口是否 active,必要时先.switch_to.window切过来再操作。
4.2 HTML5 原生拖拽不触发,被 JS 兜底解决
这是我踩过最深的一个坑。Selenium 的drag_and_drop对很多原生 HTML5 拖放事件支持得并不好,原因在于它模拟的是鼠标物理事件,而 HTML5 拖拽依赖的是dragstart、dragover、drop这类自定义事件,这两者不是一回事。
如果动作链已经把拖拽动作做了,但页面排序没变化,多半就是走了这个分支。这种情况下,正道是直接用 JavaScript 注入模拟拖拽事件的函数:
driver.execute_script(""" function dispatchDragEvent(type, target) { const e = new Event(type, { bubbles: true, cancelable: true }); target.dispatchEvent(e); } const source = arguments[0]; const target = arguments[1]; dispatchDragEvent('dragstart', source); dispatchDragEvent('dragenter', target); dispatchDragEvent('dragover', target); dispatchDragEvent('drop', target); dispatchDragEvent('dragend', source); """, source, target)这段代码在 Vue/Kendo UI 这类带自定义拖拽实现的组件里表现稳定。当然,优先顺序是:先试原生 ActionChains,无效再用 JS 兜底,不要一上来就全盘 JS,毕竟 JS 方案绕过了真实鼠标事件,部分场景下可能丢失拖拽的动画过程。
4.3 move_by_offset 的坐标陷阱
move_by_offset(x, y)的基准点很容易让人误解。它不是“页面左上角”,而是“鼠标当前所在位置”。如果你在链条中间使用它,基准点是上一步动作结束后鼠标停留的位置。
我见过不少测试代码这样写:先move_to_element(source),然后move_by_offset(50, 0),本意是想向右移动 50 像素,结果鼠标可能已经飘到别的元素上方,50 像素的偏移根本不够或者方向完全反了。
解决方式是尽量少用连续的相对偏移,改用move_to_element_with_offset明确指定“相对某个元素左上角的偏移”,比如:
ActionChains(driver) \ .move_to_element_with_offset(source, 10, 20) \ .click() \ .perform()4.4 元素刚被刷新,动作却还引用着旧的 WebElement
页面采用局部刷新后,同一个元素虽然定位表达式不变,但它已经是一个“陈旧元素”,这时候动作链虽然不会立即报错,但执行时可能命中不到任何东西。遇到这种情况,在 perform 之前要重新定位元素:
def safe_click_with_action(driver, locator): target = driver.find_element(*locator) ActionChains(driver).move_to_element(target).click().perform()这段代码看起来简单,但实际项目里很多人会在循环里反复用同一个source变量,导致第二次循环怎么点都没反应。对策只有一个:每次动作链执行前,重新获取元素引用。
4.5 reset_actions 到底什么时候用
reset_actions()是用来打断当前待执行队列、清理按键状态的。如果你在前一个链条里做了key_down(Keys.CONTROL)但没执行到key_up,或者操作到一半想放弃,就可以调用它避免后续操作被污染。
我在自动化回归框架里习惯把它放在每个 case 的finally块中:
try: ActionChains(driver).key_down(Keys.CONTROL).send_keys('a').perform() finally: action = ActionChains(driver) action.key_up(Keys.CONTROL) action.reset_actions()这样即使前面的链条执行失败,也不会影响下一条用例。
5. 稳定性优化:我在真实项目中的几条铁律
5.1 把链拆短,能两步不走十步
很多人喜欢把一长串操作写成“链式调用的艺术品”,可读性差就算了,稳定性还差。链条越长,任何一个中间步骤因为网络延迟、渲染异常而失败,排查成本就越高。
我的习惯是:把一系列操作按“业务动作”拆分,一个业务动作内部用一条链,动作之间用显式等待隔开。比如“悬浮菜单 -> 点击子项”可以是一条链,但“点击子项 -> 填写表单 -> 提交”就不适合强行塞成一条长链。拆分后的每一条链都短小、意图明确,出错了也容易定位。
5.2 加入人性化节奏,减少误报
如果一套用例被测试环境本身拖慢,动作链执行得过快,页面元素还没进入可点击状态,用例就会报错。但如果你在每个动作前面都加time.sleep(1),整套几百个用例跑下来又慢得让人崩溃。
我的折中方案是两层配合。动作链内部的关键节点只加 0.2~0.5 秒的pause,用来照顾 CSS 动画;动作链之间的状态同步交给WebDriverWait,等元素真正可交互再进入下一步。这两种手段各管一段,既能保证稳定性,又能控制整体耗时。
5.3 与重试、日志框架搭配
ActionChains 的失败有个特点:同样是perform(),有时候第一次失败第二次就成功,因为页面元素状态只是“差一点就绪”。在核心流程里不要让它裸奔,建议包一层简单重试。
def retry_action(action, max_retry=3): for i in range(max_retry): try: action.perform() return except Exception as e: if i == max_retry - 1: raise # 重置动作链,避免上次残留事件影响下一次 action.reset_actions() time.sleep(0.5)配合日志输出,把每次 perform 的耗时、成功与否记下来。这样就算用例半夜挂了,第二天看日志也能一眼确定是“动作没执行成功”还是“页面根本没加载出来”。
回到开头说的那句话:ActionChains 是被低估的类,但它的高级用法并不神秘。核心就是掌握事件队列模型、理解鼠标键盘状态机、熟悉每个 API 的执行细节,再多跑几个真实项目里的坑,自然就能游刃有余。
我个人体会最深的一点是:真正难的不是 API 本身,而是“模拟得像人”。动作链给了你完整的事件控制权,但用得好不好,取决于你愿不愿意花时间观察真实用户的手势节奏、思考页面在每个阶段的状态变化。多在你的测试代码里加一点节奏,少一点“瞬间完成”,自动化用例的稳定性和可信度都会明显上一个台阶。