
1. 为什么一个“看不见”的元素会让自动化脚本全线崩盘各位做自动化测试的同学一定都遇到过这个诡异场景脚本跑了几百次都很稳今天突然报NoSuchElementException你打开浏览器手动一查元素明明就在页面上而且id和class都漂亮得不行。这时候你十有八九是碰上了shadow-root。我第一次碰到这个问题是在测试某个用 Web Components 改造后的页面组件时。改动之前定位器跑得好好的前端团队把组件重构成自定义元素后同一套脚本直接挂了一半。当时我对 Shadow DOM 的理解还停留在“听说过这个名词不知道具体怎么回事”排查起来真的非常抓狂。后来把shadow-root的原理、定位方式摸清楚之后才发现这问题其实并不玄乎只要找对方案几分钟就能搞定。这篇文章我不讲太多理论堆砌就说三套我自己在项目里反复用过、验证过的解决方案。它们分别是Selenium 4 原生的shadowRoot接口、利用execute_script做 JavaScript 穿透以及先枚举页面元素结构再配合定位元数据统一查询的工程化做法。每一套方案我会把适用场景、代码写法、以及容易踩的坑都摆出来。不管你是刚接触 Selenium 的新手还是在维护几万行用例的资深测试开发都能照着直接用。1.1 Shadow DOM与iframe有什么不同要搞清楚为什么定位不到先得明白shadow-root到底是什么。你可以把它理解成一个自带封闭边界的“迷你 DOM 容器”普通的页面元素正常挂在文档树里而 shadow root 内部挂了一棵完全独立的“影子树shadow tree”。外部代码只能看到它的宿主元素host element默认情况下是看不到影子树内部节点的。很多同学会拿 iframe 类比说这俩挺像的。但区别非常大。iframe 是一个独立的浏览器文档上下文你可以通过driver.switch_to.frame()切进去进去之后就跟操作正常页面一样。shadow root 并不是另一个文档它依然属于当前页面的同一份 DOM 体系只是设置了一道逻辑边界。如果你用document.querySelector去查在大多数情况下来能拿到 host 元素但拿不到它内部的节点。这就是问题的根源selenium 的标准定位方式底层走的是文档级别的遍历遇到 shadow boundary 会直接停住你给它一个再准确的 CSS 选择器它也跨不过去。这是浏览器安全性和封装性的设计决定不是 selenium 的 bug。1.2 开放Shadow Root与封闭Shadow Root在实际页面里shadow-root常见的有两种模式open和closed。这两个词会在元素的attachShadow({ mode: open })或{ mode: closed }时被确定下来。open 模式外部 JavaScript 可以通过element.shadowRoot访问到影子树根节点大多数第三方 UI 组件库比如一些比较老版本的 Element UI、Ant Design 的部分 Web Components 版本、Google Material Web Components默认都是 open 模式。closed 模式element.shadowRoot会返回null外部脚本拿不到影子树引用只能靠浏览器内部的全局对象来访问某些安全要求高的业务组件会刻意用这种模式。这俩的区别对自动化测试影响很大。后面要讲的方案一和方案二都只对 open 模式生效closed 模式基本只有少数旁路办法或者需要开发配合调整。所以拿到问题用例时第一件事不是急着写定位代码而是先按 F12 看一下目标元素的shadow-root标签是#shadow-root (open)还是#shadow-root (closed)。1.3 为什么find_element“翻遍全家”也找不到shadow内部元素Selenium 执行find_element时使用浏览器原生的 DOM 查询接口如querySelector在整个 document 里去找匹配节点。从规范层面说它可以定位到普通 DOM 中的节点但对于嵌套在 shadow tree 里的节点除非你显式先拿到 shadow root再在 shadow root 的上下文里进行查询否则就是查不到。再说通俗一点以前你靠find_element(By.ID, username)能在一整棵树上找到目标元素有了 shadow root 后这棵树的中间被隔出了几个“独立小房间”房间里的东西不参与大树层面的大搜索。你光靠一个大搜索当然找不到。它也根本不报“元素被隐藏”或“元素不可点击”而是直接告诉你“没有这个元素”排查起来很容易误导人。理解到这一层后面方案的核心思路就非常清楚了要么想方法跨过 shadow boundary拿到 shadow root 和内部节点的引用要么干脆枚举所有 shadow 结构建立一份映射表让查询变成按图索骥。2. 方案一用Selenium 4原生shadowRoot接口把代码写干净Selenium 4 在 WebDriver 规范中正式加入了 ShadowRoot 接口。这玩意儿对测试从业者来说可以说是“官方开的口子”你只需要拿到宿主元素然后调用shadow_root属性就能拿到一个代表 shadow root 的对象再基于这个对象继续find_element。2.1 版本与运行环境要求Selenium 版本至少 4.0.0建议直接用最新 4.x比如 4.24 之后的版本都挺稳。Chrome 浏览器建议 96 版本以上。ChromeDriver 和浏览器内核版本尽量保持一致别用太老的驱动带新浏览器容易出各种奇奇怪怪的报错。Firefox 对应版本也有支持但我团队这边主要用 Chromium 系后面代码示例都以 Chrome 为例。Python 侧直接pip install selenium即可安装之后确认一下selenium.__version__别还停留在 3.x 老版本。如果你的项目还是 Selenium 3也别慌后面方案二不用升级也能用。但从长期维护角度看还是建议尽快迁到 Selenium 4因为官方对 3.x 的新特性支持基本已经停掉了。2.2 一级shadow root定位实操我先说最简单的情况目标元素只在某一层 shadow root 里面。假设页面结构大致是这样custom-input nameusername #shadow-root (open) input classinner-input placeholder请输入用户名 / /custom-input标准写法会直接driver.find_element(By.CSS_SELECTOR, .inner-input)但这样会找不到。用原生 shadowRoot 接口代码是这样from selenium import webdriver from selenium.webdriver.common.by import By driver webdriver.Chrome() driver.get(http://your-test-page.com) # 1. 先找到 shadow host 元素 host driver.find_element(By.CSS_SELECTOR, custom-input) # 2. 拿到 shadow root shadow host.shadow_root # 3. 在 shadow root 之下继续定位 inner_input shadow.find_element(By.CSS_SELECTOR, .inner-input) inner_input.send_keys(tester)这里最关键的一点shadow_root不是 WebElement而是一个ShadowRoot对象但它实现了SearchContext接口所以你可以直接在上面继续调用find_element和find_elements。我之前在带团队时很多人第一次写会把shadow.find_element写成driver.find_element结果照样报错。注意看定位动作必须发生在shadow这个上下文下面而不是driver下面。2.3 嵌套shadow root链式调用真实业务里更常见的是组件套组件shadow root 里面又套了一个自定义组件新的 shadow root 又出现了。这种情况也非常好处理一层一层往下拿就行。假设页面结构mega-widget #shadow-root (open) header-panel #shadow-root (open) button classsave-btn保存/button /header-panel /mega-widget定位代码mega driver.find_element(By.CSS_SELECTOR, mega-widget) header_shadow mega.shadow_root header_host header_shadow.find_element(By.CSS_SELECTOR, header-panel) button_shadow header_host.shadow_root save_btn button_shadow.find_element(By.CSS_SELECTOR, .save-btn) save_btn.click()有没有觉得很像一层层剥洋葱确实就是这样。每一层 shadow root 都是一个独立的查询范围想继续向下走就要先拿到对应的 host 元素再取其 shadow root。这种写法的最大好处是代码意图非常清晰。后来人看到host.shadow_root就知道这里是一个 shadow DOM 边界不会一脸懵。你可以把它类比成走楼梯从大厅走到房间再从房间走到套间一层层门槛过。2.4 使用shadowRoot接口容易踩的三个坑第一shadow_root只对 open 模式有效。你如果对 closed 模式调用host.shadow_rootSelenium 会直接抛ElementNotInteractableException或者返回None。所以在代码里最好先判断一下shadow host.shadow_root if shadow is None: raise ValueError(该宿主元素的shadow root为closed模式无法通过原生接口定位)第二动态渲染问题。很多 Web Components 组件是在数据加载完成之后才往 shadow DOM 里插入内容。如果页面刚加载完你就急着去定位内部元素拿到 shadow root 了但里面内容是空的照样报错。建议在定位前等待组件渲染完成用WebDriverWait配合条件函数。这里有个细节WebDriverWait传的条件如果是element_finder它会自动忽略找不到的异常但遇到 shadow root 为 None 的情况可能会直接抛错。我的习惯是写一个辅助函数把“拿到 shadow root”和“在 shadow root 内找到元素”这两个动作都塞进等待逻辑里from selenium.webdriver.support.ui import WebDriverWait def find_in_shadow(driver, host_selector, css_selector, timeout10): def _find(driver): host driver.find_element(By.CSS_SELECTOR, host_selector) shadow host.shadow_root if shadow is None: return None return shadow.find_element(By.CSS_SELECTOR, css_selector) return WebDriverWait(driver, timeout).until(_find)第三不是所有定位策略都适用于 shadow root。ShadowRoot对象里的find_element支持By.CSS_SELECTOR、By.XPATH等但实际别人反馈最多的问题在 XPath 上。shadow root 内部对 XPath 的支持在不同浏览器里表现差异很大遇到这种情况优先改用 CSS 选择器真的会省很多麻烦。3. 方案二用execute_script穿透专治“老项目”和临时定位方案一虽然好用但依赖 Selenium 4 的新接口。如果你的项目还在用老版本 Selenium、或者测试环境里的浏览器驱动版本偏旧那一时半会儿迁移不现实。这个时候最快速实用的方法就是driver.execute_script()直接跑 JavaScript手动乙级爆破 shadow root 的边界。3.1 为什么JavaScript能穿透shadow root其实上一节已经说了一半只要 shadow root 是 open 模式element.shadowRoot这个属性就是可用的JavaScript 可以直接拿到影子树根节点再调用querySelector定位内部元素。Selenium 没有直接暴露这条路径但它允许我们执行自定义脚本所以理论上所有 open 模式的 shadow root 都能用 JS 穿透。理解这个原理你就能分清方案一和方案二的区别方案一是 WebDriver 规范里封装好的“正规军”而方案二是直接用底层能力进行“渗透”。虽然是钻空子的思路但胜在兼容性极强Selenium 3 和 Selenium 4 都能用。3.2 快速写一个通用穿透函数最简单的用法是只找一层 shadow rootdef get_shadow_element(driver, host_selector, target_selector): script const host document.querySelector(arguments[0]); if (!host || !host.shadowRoot) { return null; } return host.shadowRoot.querySelector(arguments[1]); return driver.execute_script(script, host_selector, target_selector)使用方式inner_input get_shadow_element( driver, custom-input, .inner-input )这里有一个非常重要的细节execute_script里的arguments[0]和arguments[1]对应的是后面传入的两个字符串参数。也就是我会把host_selector和target_selector都拼到脚本外面而不是硬编码在脚本里。这样做的好处是函数可以复用不用每次改 JS 字符串。另外很多人会忽略返回值类型。execute_script返回节点时Selenium 会自动把它包装成WebElement对象所以拿到返回值之后你可以直接调.click()、.send_keys()等常规方法。但如果脚本里返回的是一个普通的 JavaScript 变量比如字符串、布尔值那就只会返回对应的 Python 类型。自己封装的时候要留意。3.3 解决嵌套shadow与递归调用如果页面里有嵌套 shadow root那就不能只穿透一层了。这时候我们需要一个能递归深入的脚本。def find_shadow_element_by_path(driver, path): :param path: 定位路径列表例如 [mega-widget, header-panel, .save-btn] script let current document; const path arguments[0]; for (let i 0; i path.length - 1; i) { const host current.querySelector(path[i]); if (!host || !host.shadowRoot) { return null; } current host.shadowRoot; } const lastIndex path.length - 1; return current.querySelector(path[lastIndex]); return driver.execute_script(script, path)调用时btn find_shadow_element_by_path( driver, [mega-widget, header-panel, .save-btn] )这段脚本的循环逻辑也很好懂前n-1段是 shadow host 的 selector每一层都把自己切到host.shadowRoot上下文里最后一段 selector 在最后一个 shadow root 内部查找目标元素。路径里如果插入一个不存在的前置节点循环会直接返回None避免报出漫天异常。这个方法是我当时在老项目中用得最多的。因为老旧项目的页面结构非常混乱光靠一层层写shadow_root看代码看得眼睛疼用这个路径列表法反而清爽很多。3.4 这个方案的局限和注意点用execute_script穿透虽然灵活但有几个硬伤。第一如果 shadow root 是 closed 模式host.shadowRoot拿到的就是null这个方案同样失效。对 closed 模式只有一些偏门办法比如调试模式下用window.__shadowRoot之类的内部变量但这根本不是稳定测试方案。遇到 closed 模式我建议直接找前端同学协调看能不能把组件改成 open 模式或者给组件暴露一个测试专用的 API。第二频繁执行 JS 的维护成本偏高。脚本一旦涉及复杂的 DOM 操作字符串变长之后很容易出错调试也费劲。如果你只是临时救场用没问题但如果是几十个用例里到处都这样写后面维护的人真的要骂娘。第三注意脚本执行时机。execute_script不会等元素渲染完成也不会自动重试。如果页面组件是异步渲染的你这个函数很容易拿到null。所以每当用 JS 定位时我都会在外层补一个WebDriverWait或者先睡眠等待组件挂载完成再执行脚本。4. 方案三页面元素枚举定位元数据复杂页面的兜底方案要是在一个特别复杂的业务系统里页面中嵌套的 shadow root 有七八层每个列表项里都有一堆自定义组件那光靠前两种方案还不够优雅。每写一个用例都要手工数层级、拼接 selector这个工作量实在太大而且前端一改组件结构用例又崩成一片。这时候就该祭出工程化打法了先枚举页面上的所有 shadow 结构把定位元数据统一管理起来再让测试代码按兵不动地按元数据查询。4.1 思路先给页面shadow结构建“地图”所谓枚举enumerate就是跑一段 JS把整个页面里的 shadow host、shadow root 层级、以及 open/closed 状态都遍历出来形成一张“页面上有哪些 shadow 根节点”的地图。然后我们把这张地图的信息存成结构化的定位元数据后续所有用例不再傻乎乎地写死定位表达式而是查这张 “地图”按路径定位。这么做的好处是显而易见的减少重复劳动只需要一次性梳理页面结构后续测试方法都复用同一个查询入口。降低变更影响组件内部结构调整时往往只需要更新定位元数据文件测试逻辑不用跟着改。提高可读性测试代码里不再是一长串魔法字符串而是一个清晰的属性名。这跟许多框架里“页面对象模型Page Object Model”的思路一脉相承把定位细节和测试行为拆开。区别在于这里我们额外多了一层“shadow root 地图”。4.2 实现一个shadow root枚举器直接给出一段可以放进execute_script的枚举脚本function collectShadowRoots(root) { const results []; function walk(node, currentPath) { const elements node.querySelectorAll ? node.querySelectorAll(*) : []; elements.forEach((el) { if (el.shadowRoot) { const tag el.tagName.toLowerCase(); const path currentPath.concat([tag]); results.push({ path: path, tagName: tag, mode: el.shadowRoot.mode, id: el.id || }); if (el.shadowRoot.mode open) { walk(el.shadowRoot, path); } } }); } walk(root, []); return results; } return collectShadowRoots(document);这段脚本遍历当前页面的所有普通 DOM 节点找到一个 shadow host就记录下它的 tagName、id、路径和 mode如果它是 open 模式继续往它内部的 shadow tree 递归遍历。在 Python 侧我们可以把枚举结果整理成 JSON方便调试和存档shadow_map driver.execute_script(collect_script) import json with open(shadow_map.json, w, encodingutf-8) as f: json.dump(shadow_map, f, ensure_asciiFalse, indent2)输出的 JSON 大概是这样的[ { path: [mega-widget], tagName: mega-widget, mode: open, id: }, { path: [mega-widget, header-panel], tagName: header-panel, mode: open, id: } ]有了这张图你就能一目了然地看到页面的 shadow 嵌套层级有多深、哪些 shadow root 是 open、哪些是 closed。排查定位问题时先看这张图往往就能判断到底是路径写错了还是遇到了 closed shadow root。4.3 维护一份定位元数据文件枚举器只是“地图”真正提升效率的是把这套结构和用例中的定位信息打通。我通常会在项目里维护一个独立的locators.yaml或者 Python 字典文件把常用组件和它们对应的 shadow 路径记下来# locators.yaml login: username: path: [login-widget, inner-form, input.username] password: path: [login-widget, inner-form, input.password] submit: path: [login-widget, action-panel, button.submit] header: user_avatar: path: [mega-widget, header-panel, user-avatar]然后在 Python 代码里封装一个统一的查询方法import yaml class ShadowLocator: def __init__(self, driver, locator_file): self.driver driver with open(locator_file, r, encodingutf-8) as f: self.locators yaml.safe_load(f) def find(self, page, element_name): locator_path self.locators[page][element_name][path] return find_shadow_element_by_path(self.driver, locator_path) def click(self, page, element_name): el self.find(page, element_name) el.click()使用时locator ShadowLocator(driver, locators.yaml) locator.click(login, submit)这样的好处是测试用例的意图非常清晰。产品需求改动导致组件层级变化时不用去几十个用例文件里翻找只改locators.yaml一个文件就行。哪怕不是 team 里的同学看到locator.click(login, submit)都知道这是在点登录页的提交按钮。4.4 适用场景与维护要点这个方案适合什么场景我的经验是三种情况最值得用系统重度使用 Web Componentsshadow 嵌套层级很深页面里同类型组件反复出现。用例数量很多团队有多个人并行维护自动化脚本需要统一约定。项目前期基建还可以愿意花半天时间把页面结构摸清并建立定位元数据体系。但它也不是没有成本。最大的成本就是初期梳理页面结构需要投入人力和时间另外如果前端组件非常动态每次版本都会改名、改层级这套体系维护起来也会很吃力。我的建议是每次发版后跑一遍枚举器把新的shadow_map.json和上一版做 diff哪块结构变了直接看差异比盲猜快得多。不过这里要特别提醒枚举器拿到的 shadow path 是“按组件先后顺序”生成的纯通用选择器。如果页面上有同名的多个组件实例比如列表里有 10 个list-item单靠tagName做路径肯定是定位不准的。这种情况我会在枚举脚本里额外记录一些特征比如>