☰
JS注入破解自动化测试难题:readonly、Shadow DOM与React控件
2026/10/24 3:14:24 网站建设 项目流程

写这个系列写到了第七篇,才终于轮到JavaScript专题。前几篇一直教大家怎么稳定定位元素、怎么写等待条件、怎么切iframe,但总有那么几个元素,官方API就是搞不定:readonly的输入框非要手输日期、React组件明明显示输入了值提交却读不到、Shadow DOM内部的结构怎么都定位不着,点击按钮还不时被浮层拦截。这些场景靠常规操作硬刚,消耗的时间足以让人怀疑人生。换一下思路,直接往页面里执行一段JavaScript,往往几行代码就解决。

这篇文章不聊JS语法基础,也不教前端开发,聚焦测试脚本里最常用的那些JS片段。我会把使用场景、执行原理、参数传递和返回值机制一并说清楚,再附上可以直接抄进去的代码。正在用Selenium、Playwright这类WebDriver系框架的测试工程师,只要你能写基础脚本,这篇就能帮你把脚本稳定性往上拉一截。

1. JavaScript在自动化测试中到底能干什么

1.1 为什么官方API不够用

先想一个问题:Selenium已经很接近“模拟用户”了,为什么还是搞不定那么多控件?因为WebDriver协议的本质是通过浏览器驱动暴露一层“受控代理”,它提供的是模拟用户输入、点击、滚动这些行为。可很多业务场景不是“行为”能解决的,比如直接修改指定元素的readonly属性、把disabled的按钮临时启用、往React底层框架里塞一个值并让它“以为”是用户输入的。这些动作更像是在运行时里改状态,而不是模拟操作。

另一个绕不开的例子是Shadow DOM。自定义组件为了封装样式和结构,会把内部DOM隔离到Shadow Root里,普通find_element走的是document.querySelector,根本看不到隔离层内部。React、Vue这类框架衍生出的受控组件也有类似问题,官方send_keys输入了文本,但框架内部的value tracker认为值没变,提交表单时拿到的还是旧数据。真正能穿透这些问题的,就是JavaScript。

1.2 execute_script的底层执行逻辑

Selenium里执行JS的入口是execute_script,它做的事情很直接:把脚本字符串发送给浏览器驱动,驱动再让浏览器在当前的页面上下文里执行。执行结果会序列化回测试语言,在这边可能是Python的str、int、bool、list、dict,如果JS返回值是DOM节点,它还会被自动包装成一个WebElement对象继续用。这种“过去的东西还能带回来”的设计很方便,但也藏了不少坑。

有一个核心规则要记住:脚本里接收外部数据,一律通过arguments传参,不要把数据直接拼进字符串。比如你想获取一个动态选择器对应的文本,正确写法是这样:

text = driver.execute_script("return document.querySelector(arguments[0]).textContent;", selector)

这样能避免引号嵌套和转义问题,也避免用户输入被当成脚本代码执行。另外要注意执行上下文,execute_script默认作用在“当前选择的frame或窗口”上,如果你在iframe里找元素,要先switch_to.frame切进去,否则脚本里写document.querySelector找的还是外层页面。

1.3 三条使用原则:把JS当“补丁”而不是“主菜”

我见过一些测试同学,定位不到元素就一律上JS,脚本越写越“炫”,维护起来苦不堪言。后来我在团队里定了三条规则,效果很明显。

第一,能用原生API解决的,优先用原生API。element.click()、element.send_keys()这些接口更接近真实用户行为,JS强制点击有时不会触发浏览器默认行为,比如input[type=file]的弹窗,JS调用click()基本带不出来。

第二,脚本保持单一职责,并且返回明确结果。能返回布尔值、文本、数字就绝不让脚本只是“执行一下”,这样写出来的脚本可以做断言,也可以接进显式等待条件。

第三,JS脚本不是法外之地。所有动态内容走arguments传参,脚本内部不要拼外部变量,否则维护时会疯掉。后面我会反复强调这条。

2. 高频JS操作:可以直接复制到工程里的实用脚本

2.1 滚动、隐藏元素与强制点击

滚动是最常见的需求。很多页面元素不在首屏,element.click()会先帮你滚动到元素再点击,但偶尔会滚过头或滚不到,尤其遇到固定的浮层。我更常用的是:

driver.execute_script("arguments[0].scrollIntoView({block: 'center', inline: 'center'});", el) driver.execute_script("window.scrollBy(0, document.body.scrollHeight)")

scrollIntoView可以控制元素出现在视口的中间位置,实测下来比WebDriver默认的“滚动到能点击”稳定。第二个写法适合处理无限滚动列表,滚到页面底部触发加载。

点击被遮挡或不可见元素时,我一般分两种情况。如果只是被悬浮层挡住,但元素本身可以交互,直接触发原生click事件:

driver.execute_script("arguments[0].click();", el)

如果页面组件监听的是完整的鼠标事件链,比如mousedown、mouseup、click,则需要手动派发一系列事件:

driver.execute_script(""" const el = arguments[0]; el.dispatchEvent(new MouseEvent('mousedown', {bubbles: true, cancelable: true})); el.dispatchEvent(new MouseEvent('mouseup', {bubbles: true, cancelable: true})); el.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true})); """, el)

提示:JS触发click()不会经过可见性、可点击性这些WebDriver检查,也不会产生真实的鼠标坐标移动。如果你是为了测试“用户能不能点这个按钮”,请回到底层思考一下这个绕过是否合理。

2.2 使用原生setter给输入框赋值,触发框架监听

这个是我在测试React项目时踩过大坑后总结下来的。直接通过element.send_keys()输入,或者通过element.set_attribute('value', xxx)赋值,在React受控组件上经常无效——页面看起来值变了,但框架内部的“最新值”还是旧的,提交表单时拿不到输入内容。

原因在于React会在初始化时重写value属性的setter,它用自己内部的value tracker判断值是否变化。我们要绕过这层,必须调用HTMLInputElement.prototype上原始value的setter,然后再派发input事件:

def js_set_value(driver, element, value): driver.execute_script(""" var el = arguments[0]; var value = arguments[1]; var proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : el instanceof HTMLSelectElement ? HTMLSelectElement.prototype : HTMLInputElement.prototype; var setter = Object.getOwnPropertyDescriptor(proto, 'value').set; setter.call(el, value); el.dispatchEvent(new Event('input', {bubbles: true})); """, element, value)

这段代码对<input>、<textarea>、<select>都做了适配。如果你用的是Vue,v-model监听的是input事件,这个写法通常也有效;如果是v-model.lazy,把事件换成change即可。遇到某些组件还需要先focus一下再赋值、再blur,可以在脚本里补上。

2.3 去除readonly和disabled属性后再操作

日期类控件经常设置readonly,不允许手动输入,测试时又不想每回都去日历面板里点来点去。简单粗暴但有效的方法是先去掉readonly,再用send_keys输入:

driver.execute_script("arguments[0].removeAttribute('readonly');", el) el.send_keys("2025-06-01")

如果某个按钮是disabled状态,但你确定要测它被点击后的行为,可以临时启用:

driver.execute_script("arguments[0].disabled = false;", el)

要注意两件事。第一,这些属性改动可能触发页面联动逻辑,导致提交的数据和预期不一致,操作完最好恢复原属性。第二,用这种方式时一定要想清楚:你是想测用户场景,还是想绕过产品限制?如果是前者,请改用真实交互路径。

2.4 用JS读取页面状态与性能指标

自动化脚本最怕的是“元素出现了但其实没就绪”,常见判断方法是查document.readyState或页面里某个标志变量。JS在这里非常方便:

state = driver.execute_script("return document.readyState") has_data = driver.execute_script("return !!(window.appData && window.appData.ready)")

取页面性能数据也可以直接拿:

perf = driver.execute_script(""" var entries = window.performance.getEntriesByType('navigation'); return entries.length ? JSON.stringify(entries[0]) : ''; """)

配合显式等待能做很优雅的轮询:

WebDriverWait(driver, 10).until(lambda d: d.execute_script("return window.jQuery ? window.jQuery.active === 0 : true"))

这段的意思是等jQuery的请求全部结束。如果页面没有用jQuery,可以换成判断某个资源列表的变化。这种“JS执行结果作为等待条件”的方案,比固定sleep靠谱得多。

3. 硬骨头:Shadow DOM与前端框架控件的自动化破局

3.1 Shadow DOM为什么常规定位找不到

自定义元素为了隔离样式和DOM结构,会把内部内容放进Shadow Root。普通DOM树里只能看到宿主标签,看不到里面的细节,所以find_element找不到子节点是正常的。要进去,必须通过JS拿到shadowRoot属性。

对于open类型的Shadow DOM,可以直接用下面的方式拿内部元素:

host = driver.execute_script("return document.querySelector('my-component')") target = driver.execute_script(""" const shadow = arguments[0].shadowRoot; return shadow.querySelector('.target'); """, host)

返回的target会自动包装成WebElement,之后你可以正常点击或等待。但如果是多层嵌套,比如页面里有个my-shell,里面又包了个my-input,我建议在同一段脚本内穿到底层,不要在Python和JS之间反复横跳:

driver.execute_script(""" let root = document.querySelector('my-shell').shadowRoot; let nested = root.querySelector('my-input').shadowRoot; let input = nested.querySelector('input'); input.focus(); input.value = 'hello'; input.dispatchEvent(new Event('input', {bubbles: true})); """)

有人想把shadowRoot本身返回给Python然后继续find_element,我不建议这么做。ShadowRoot不是标准的WebElement,不同版本的驱动对它的包装不稳定,最稳的方式是在一段JS里完成查找和操作。

注意:closed类型的Shadow DOM无法通过shadowRoot属性访问,这是浏览器的安全隔离机制,不要跟它硬刚。遇到这种控件,优先拉着开发加一个“测试钩子”,比如在组件上暴露一个供自动化使用的对象。

3.2 富文本编辑器与Canvas签名组件

富文本编辑器的内容通常在一个iframe内部,或者是一个contenteditable元素。操作iframe时先切进去再执行JS:

driver.switch_to.frame(dom_iframe) driver.execute_script(""" document.querySelector('[contenteditable="true"]').innerHTML = '<p>test content</p>'; """)

innerHTML赋值虽然快,但有些编辑器框架不会同步内部的脏状态,敲完再提交可能丢内容。更稳的替代方案是document.execCommand('insertHTML', false, 'test content'),但execCommand在部分现代浏览器已被标记废弃,具体还得看组件实现。我的一般做法是:先看编辑器暴露了什么接口,能调用官方API就优先调用。

Canvas签名组件就更有意思了。Canvas本质上是一块画布,没有DOM节点可以输入,JS也没办法真正模拟人手绘制的轨迹。如果业务组件内部没有暴露类似setSignature(imageData)的测试方法,自动化只能走屏幕坐标模拟绘制或者做图像比对,这两者都脆弱。所以我遇到Canvas签名,通常直接找前端同学商量一个测试入口,这是最省力的路。

4. execute_async_script:等待异步操作的正确姿势

4.1 为什么不能用同步脚本“等”Ajax

用execute_script写一个setTimeout,你以为浏览器会等三秒再返回?不会。execute_script是同步接口,它只负责把脚本执行完,不等脚本里的异步回调。脚本执行完那一刻,setTimeout还没触发,驱动已经拿着返回值走了。

这就需要用另一个方法:execute_async_script。它会往脚本里塞一个callback回调函数,只有你调用callback,驱动才知道脚本完成,期间会一直阻塞等待。等多久由driver.set_script_timeout()决定,默认一般是30秒。

你可以理解为同步脚本是快递柜投递,投完就走;异步脚本是当面签收,签收单没交回去,快递员就一直等着。

4.2 一个“等到页面请求全部完成”的标准模板

实战中我经常用异步脚本等页面内部的网络请求完成,最简单的版本是等jQuery的请求清空:

driver.set_script_timeout(10) result = driver.execute_async_script(""" let callback = arguments[arguments.length - 1]; (function waitActive() { if (window.jQuery && window.jQuery.active > 0) { setTimeout(waitActive, 100); } else { callback('ok'); } })(); """)

如果页面不用jQuery,可以用performance.getEntriesByType('resource')观察资源加载数量是否稳定:

driver.execute_async_script(""" let callback = arguments[arguments.length - 1]; let firstCount = performance.getEntriesByType('resource').length; setTimeout(function check() { let currentCount = performance.getEntriesByType('resource').length; if (currentCount !== firstCount) { firstCount = currentCount; setTimeout(check, 200); } else { callback(currentCount); } }, 200); """)

这个模板的思路是:先记录当前资源数,等两百毫秒再查一次,如果数量没变化,就认为网络空闲。它不是万能药,但对付大多数SPA页面的首屏异步加载足够了。

4.3 回调机制与超时陷阱

写异步脚本最容易踩的坑是:脚本内部抛了异常,没调用callback,驱动就一直等,最后抛TimeoutException。排查半天才发现是脚本里一个变量名写错了。所以只要脚本稍微复杂,我都习惯套一层try/catch:

driver.execute_async_script(""" let callback = arguments[arguments.length - 1]; try { let data = window.someFunction(); callback(data); } catch (e) { callback('ERROR: ' + e.message); } """)

另外要记住,异步脚本里不能用return代替callback,返回值必须通过回调传出来。回调函数也最多调一次,调两次的行为在不同驱动里不一样,别赌它一定只认第一次。正因如此,异步脚本的职责越单一越好,不要把一堆逻辑塞进去。

5. 我踩过的那些JS脚本“深坑”与排查速查表

5.1 注意引号嵌套与参数传递

写execute_script时,Python三重双引号和JS单双引号混在一起,很容易出现引号错位。我的建议是:脚本里的字符串统一用单引号,外层Python保持三重双引号;动态数据绝不进字符串,全部走arguments。

比如这样写没问题:

driver.execute_script("return document.querySelector(arguments[0]).getAttribute('data-id');", '.item')

如果非要在脚本里生成一个动态字符串,就用模板字符串加反引号拼接,但依然要用arguments把值传进去,不要直接字符串替换。

5.2 元素在脚本返回后失效的问题

有次我在一个循环里反复使用同一个WebElement,执行几次后抛出StaleElementReferenceException。原因是用JS改了DOM,浏览器把旧节点替换掉了,而Python这边的WebElement还指向旧引用。

处理办法有两个。第一,尽量在一个脚本里完成查找和操作,避免把元素带出来再传回去。第二,如果一定要跨脚本,先把元素的唯一特征保存下来,比如># 第一次带出唯一标识 marker = driver.execute_script("return arguments[0].dataset.id;", el) # 第二次用标识重新查找并操作 driver.execute_script("document.querySelector('[data-id=\"' + arguments[0] + '\"]').click();", marker)

这比直接保存旧的WebElement稳定得多。

5.3 到底什么时候不该用JS

最后聊一个“克制”的问题。我不建议测试脚本一遇到元素不可见就立刻上JS强制点击。如果这个按钮本身就是被隐藏的,产品预期用户不可能点到,那你把它强制点击出来的用例到底在验证什么?测试的价值是守护用户可感知的正确行为,不是证明“脚本能操作一切”。

优先顺序应该是:先等元素可交互,再考虑切iframe或ActionChains模拟复杂操作,最后才是JS注入。每写一段JS,都写一行注释说明“为什么官方API不行”,这样代码评审的人也能理解,后期维护的人不至于一头雾水。

5.4 常见问题速查表

症状可能原因解决方案
执行后报JavascriptException脚本语法错误、引号嵌套错误、引用了不存在的方法先在浏览器Console里粘脚本跑一遍,确认无误再改回代码
脚本不报错但页面没反应操作的是错误iframe/窗口,或元素选择器没命中检查当前上下文,用document.querySelectorAll确认元素存在
返回值为None但预期是文本脚本里漏写return,或返回了一个Promise对象同步脚本不能返回Promise,复杂异步请改用execute_async_script
JS执行了click()但没有效果JS click缺少完整鼠标事件链手动派发mousedown、mouseup、click事件
异步脚本一直超时callback未被调用,或脚本内部异常被吞掉加try/catch,并把异常信息传给callback进行排查
WebElement突然失效页面DOM被JS重绘,旧节点引用失效用唯一特征重新查找,避免长期持有WebElement

5.5 把JS操作封装成工具函数

用得多了自然会发现,click、set_value、去readonly这些操作都适合沉淀成公共函数。我习惯维护一个js_utils.py:

class JsHelper: def __init__(self, driver): self.driver = driver def click(self, element): self.driver.execute_script("arguments[0].click();", element) def set_value(self, element, value): self.driver.execute_script(""" var el = arguments[0]; var value = arguments[1]; var proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : el instanceof HTMLSelectElement ? HTMLSelectElement.prototype : HTMLInputElement.prototype; Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, value); el.dispatchEvent(new Event('input', {bubbles: true})); """, element, value) def remove_readonly(self, element): self.driver.execute_script("arguments[0].removeAttribute('readonly');", element)

这样项目里所有人都用同一套经过验证的脚本逻辑,踩过的坑会越来越少。最后再分享一个我自己的习惯:任何稍有复杂度的JS脚本,先在浏览器Console里手工跑通,再原封不动搬回自动化代码里。第一版直接在脚本里调试,大概率会被引号折磨到怀疑人生。脚本能跑通后,再考虑封装成公共函数,起码我的项目里,这套流程能省掉八成以上的调试时间。

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

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

立即咨询