tva
← 洞察

在自动化浏览器测试中处理 Shadow DOM

Shadow DOM 为 Web 组件提供真正的封装:外部样式不会渗入,内部选择器不会越界,组件内部实现保持私有。Amazon 的 Katal 框架、Salesforce Lightning Web Components 和 Material Web Components 等大型设计系统广泛使用 Shadow DOM,正是因为这种封装让组件的行为可预测,也更易移植。

问题在于,让 Shadow DOM 在生产环境中发挥作用的封装,也给自动化测试带来了很大困难。Selenium、Cypress 以及一定程度上的 Playwright 等多数自动化框架都围绕 document.querySelector 构建,而 Shadow DOM 的设计恰恰使它无法被 document.querySelector 查询到。

Shadow DOM 为何会让自动化失效

核心问题在于影子边界。组件渲染到影子根节点后,其 DOM 子树位于独立的文档片段中,普通 DOM 查询无法穿过这一边界。如果带有相应类名的元素位于影子根节点内,即使它在页面上可见、人也能正常操作,document.querySelector(‘.checkout-button’) 这样的调用仍然找不到它。

这不是浏览器故障,而是规范按设计生效的结果。Shadow DOM 的封装阻止外部 CSS 选择器和 DOM 查询访问组件内部;外部 JavaScript 只能通过显式引用访问,例如开放根节点的 shadowRoot 属性。document 和普通 DOM 元素上的 querySelector 方法都会止步于影子边界。来看下面的结构:

<!-- Light DOM -->
<katal-button>
  #shadow-root
    <button class="kds-button">Add to cart</button>
</katal-button>

document.querySelector(‘katal-button .kds-button’) 这样的选择器会返回 null。元素存在于 DOM 中,但查询无法访问它。依赖从文档根节点发起单一选择器查询的自动化框架,会在这里悄然失效:元素并未缺失,只是被封装起来了。

开放与封闭影子根节点

影子根节点有两种模式:open 和 closed。这一区别直接决定了可以采用哪些自动化方法。

开放影子根节点通过宿主元素的 shadowRoot 属性暴露内部节点:

const host = document.querySelector('katal-button');
const button = host.shadowRoot.querySelector('.kds-button');

对于封闭影子根节点,element.shadowRoot 返回 null。一些企业组件系统在生产环境中使用封闭影子根节点,以防止外部代码依赖内部实现细节。封闭根节点的目的,就是阻止上述访问方式。

实际工作中,自动化工程师更常遇到开放影子根节点。<input type="date"> 和 <video> 等浏览器原生元素也在影子树中渲染控件,但这些用户代理根节点完全不向页面脚本或 Playwright 定位器开放。封闭根节点主要出现在企业设计系统中,需要采用根本不同的方法,下文会单独讨论。

使用 shadowRoot.querySelector 手动遍历

对于开放影子根节点,最直接的方法是显式遍历。不从文档根节点发起一次查询,而是分步穿过各个影子边界。Playwright 的 evaluate 方法在页面上下文中运行 JavaScript,可以直接遍历影子根节点:

// Doesn't work — evaluate can't return DOM elements by value
const button = await page.evaluate(() => {
  const host = document.querySelector('katal-button');
  return host?.shadowRoot?.querySelector('.kds-button');
});

// Works — evaluateHandle returns a handle to the live element
const buttonHandle = await page.evaluateHandle(() => {
  const host = document.querySelector('katal-button');
  return host?.shadowRoot?.querySelector('.kds-button');
});

await buttonHandle.asElement()?.click();

这里需要区分 evaluate 和 evaluateHandle。evaluate 会将返回值序列化后传过浏览器与 Node.js 之间的边界;DOM 节点无法序列化,因此得到的是 undefined,而非元素。evaluateHandle 则返回句柄对象,在浏览器进程中保留节点引用,供后续交互调用使用。

不过,这种方式很脆弱。组件更新如果重新渲染或替换内部节点,句柄就会指向已从 DOM 中分离的元素。对于深层嵌套,即影子根节点中再嵌套影子根节点,遍历代码会变得冗长且脆弱。有更好的方法。

穿透选择器

Playwright 的 CSS 定位器默认就会穿透开放影子根节点,因此 page.locator('katal-button .kds-button') 已能访问按钮。>> 标记用于串联选择器,每一部分都相对于前一次匹配结果进行查询,从而明确呈现从宿主到内部元素的结构:

// Pierce through the shadow root of katal-button
const button = page.locator('katal-button >> .kds-button');
await button.click();

使用 >> 时,左侧定位影子宿主,右侧相对于宿主进行查询;由于 Playwright 的 CSS 引擎会穿透开放影子根节点,查询范围也包括宿主的影子根节点及其中嵌套的开放根节点。更深的嵌套可以通过串联处理:

// Three levels deep
const input = page.locator('my-form >> my-field >> input[type="text"]');

这种写法比手动遍历简洁得多,也比 evaluateHandle 方式更能适应组件结构更新。Playwright 内置的重试机制会不断重新解析定位器,直到元素出现,从而自动处理异步渲染。

Playwright 的语义定位器 getByRole、getByLabel 和 getByText 也会穿透开放影子根节点;在 Playwright 中,除 XPath 外的所有定位器类型都具备这一行为。对于符合 ARIA 规范的组件,这是最易维护的选择,因为它测试的是行为,而非结构:

// Works if the shadow DOM button has accessible role and text
await page.getByRole('button', { name: 'Add to cart' }).click();

穿透选择器的局限是只适用于开放影子根节点。Playwright 定位器不支持封闭影子根节点。

等待影子根节点挂载

Shadow DOM 引入了普通 DOM 测试中很少暴露的时序问题。自定义元素升级并挂接影子根节点时,存在两个不同的异步事件:元素出现在 DOM 中,以及影子根节点填充内容。等待宿主元素出现,并不能保证影子内容已经就绪。

// Unreliable — waits for the host, not the shadow content
await page.waitForSelector('katal-button');
// This may still fail immediately after
await page.locator('katal-button >> .kds-button').click();

正确的方式是直接等待影子内容。Playwright 定位器会自动重试,因此 locator.click() 在多数情况下已经能处理这一问题。但对于渲染前需要获取配置的数据驱动组件,内容出现前重试就可能超时。使用有意义的选择器显式等待,更为稳健:

// Wait for a specific shadow DOM element to become visible
await page.locator('katal-button >> .kds-button').waitFor({ state: 'visible' });
await page.locator('katal-button >> .kds-button').click();

// For async-loaded components, wait for a data-ready indicator
await page.locator('katal-product-card >> [data-loaded="true"]').waitFor();

显式调用 waitFor 还能提供更有帮助的失败信息。waitForSelector(‘katal-button’) 超时表示没有找到宿主;穿透定位器上的 waitFor({ state: ‘visible’ }) 超时,则明确表示影子内容尚未就绪。排查不稳定测试时,这一区别很重要。

跨影子根节点的事件传播

跨影子边界的事件处理存在一些细节,会影响交互测试的行为。浏览器派发的点击、输入和键盘等 UI 事件是组合事件(composed: true),意味着它们会跨越影子边界传播,并冒泡到外层文档。多数其他事件则不是,包括未设置 composed: true 而创建的合成事件。

但 target 属性会被重新定位。影子宿主元素上的事件监听器看到的目标是宿主自身,而非实际触发事件的内部 Shadow DOM 元素。如果测试要验证事件目标,或应用代码使用 event.target 来识别交互对象,这一点就很重要:

host.addEventListener('click', (e) => {
  console.log(e.target);         // logs the host element, not the internal button
  console.log(e.composedPath()); // logs the full path including shadow internals
});

对于开放影子根节点,event.composedPath() 是检查交互实际过程的可靠方法;对于封闭根节点,宿主外部的监听器看到的路径会止于宿主。对于验证事件派发行为的测试,检查 composedPath() 而非 target,才能准确了解情况。

自定义事件更为棘手。它们默认采用 composed: false,完全不会跨越影子边界。组件派发内部自定义事件时,如果没有显式设置 composed: true,影子宿主外部就无法观察到该事件:

// Inside the component — this event will NOT reach outside listeners
this.shadowRoot.dispatchEvent(new CustomEvent('katal-select', {
  bubbles: true,
  composed: false, // default — event stays inside shadow root
  detail: { value: selectedItem },
}));

// Outside the component — this listener never fires
host.addEventListener('katal-select', handler); // never called

如果组件没有将事件传递到普通 DOM,就无法通过标准方式从外部测试事件派发。实际可行的选择是:在操作前通过 page.evaluate 在影子根节点内部添加观察器,或者验证事件应当引起的可观测状态变化,而非事件本身。后者通常更有意义:确认选择结果体现在组件公开属性上,比只确认事件已触发更稳健。

封闭影子根节点需要什么方法

对于封闭影子根节点,所有基于选择器的方法都无效。shadowRoot 属性返回 null,穿透选择器无法跨越封闭边界,evaluateHandle 遍历也会在同一位置失败。可行的解决路径与开放根节点确实不同。

使用公开 API。设计良好的 Web 组件会暴露特性、属性或插槽,供外部控制状态,无需访问内部。如果组件支持 aria-checked,点击组件后验证这一 ARIA 属性,既便于自动化,也符合语义。测试组件公开的接口,比测试它隐藏的实现更易维护。

进行组件级测试。Shadow DOM 组件通常可以使用 @web/test-runner 或 Lit 测试工具单独测试。这些工具提供受控环境,可通过组件自身的 API 直接测试组件。它们本身不会打开封闭根节点,但能方便地单独测试公开 API。页面级端到端测试应验证集成行为,组件级测试则可以验证内部正确性。

与组件作者沟通。封闭影子根节点是一种有意的设计选择。如果组件库阻碍合理的自动化测试,却未提供可测试的公开接口,这就是值得提出的质量问题。使用开放模式,并明确记录哪些内部结构是稳定的,是合理的折中方案。

集中管理 Shadow DOM 定位器

生产测试中可靠的做法,是集中定义明确的定位器,避免在测试中嵌入复杂巧妙的遍历代码。辅助模块封装常用穿透路径,既能保持测试可读性,也能隔离组件更新的影响:

// helpers/katal.ts
import type { Page } from '@playwright/test';

export const katalButton = (page: Page, text: string) =>
  page.locator('katal-button >> button').filter({ hasText: text });

export const katalSelect = (page: Page, label: string) =>
  page.locator(`katal-select[label="${label}"] >> select`);

export const katalInput = (page: Page, name: string) =>
  page.locator(`katal-input[name="${name}"] >> input`);

// In tests
await katalButton(page, 'Add to cart').click();
await katalInput(page, 'email').fill('[email protected]');

当 Katal 更新内部结构时,只需修改一个函数,无需逐个查找测试文件。这与页面对象模型的原理相同,只是专门应用于 Shadow DOM 引入的封装层。组件边界也成为测试辅助函数的抽象边界。

根本问题

Shadow DOM 自动化测试困难,说明测试工具尚未完全适应组件模型。围绕全局 querySelector 访问构建的框架,假定 DOM 扁平且可访问,这与 Shadow DOM 的设计目标恰好相反。

封装本身是合理的。只暴露公开接口、隐藏实现细节的组件,更易维护、升级和组合。测试中的阻力反映了工具对 DOM 访问方式的认识尚有缺口,而非 Shadow DOM 自身的缺陷。Playwright 的影子穿透定位器和语义定位器,正在推动能够适应封装的测试模式。

实践要点是:开放影子根节点使用穿透选择器,封闭根节点验证可观测状态而非内部结构,并集中管理跨影子边界的定位器,使组件更新只需修改一处,而非整个测试套件。Shadow DOM 会长期存在,自动化方法需要适应这一现实。

相关洞察

延伸阅读