☰
Storybook 跨 Shadow DOM 查询实战:基于 shadow-dom-testing-library 编写 Web Components 交互测试
2026/10/12 17:40:37 网站建设 项目流程

Storybook 跨 Shadow DOM 查询实战:基于 shadow-dom-testing-library 编写 Web Components 交互测试

【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook

当使用 Web Components(如 Lit、Custom Elements)开发组件时,许多组件的内部 DOM 被封装在 Shadow DOM 中。这带来样式与结构隔离的同时,也让 Storybookplay函数中基于 Testing Library 的常规查询(如findByRole、getByText)无法穿透阴影边界找到元素。本篇指南以 Storybook 官方交互测试文档为核心,讲解如何借助shadow-dom-testing-library提供的跨边界查询能力,在 Storybook 的canvas上直接查询 Shadow Root 内部元素,并在 CSF 3 与 CSF Next(实验性)两种格式下完成从.storybook/preview.*全局配置到 story 内断言的全流程落地。

问题背景:Shadow DOM 与常规 Testing Library 查询的冲突

在 docs/writing-tests/interaction-testing.mdx 中,交互测试被描述为"story 的一部分":story 先渲染组件到指定初始状态,再由play函数模拟点击、输入、提交表单等用户行为,最后对结果进行断言。所有查询都通过play函数上下文解构出的canvas完成,canvas是一个"包含被测 story 的可查询元素",其查询方法全部来自 Testing Library,形式为<type><subject>。

Testing Library 的单元素查询分为三类:

Type of Query0 Matches1 Match>1 MatchesAwaited
getBy...Throw errorReturn elementThrow errorNo
queryBy...ReturnnullReturn elementThrow errorNo
findBy...Throw errorReturn elementThrow errorYes

其中findBy...(及findAllBy...)返回的是 Promise,会等待元素出现后再解析,适合配合play函数中的异步交互场景。查询的 subject 则包括ByRole、ByLabelText、ByText、ByDisplayValue、ByPlaceholderText、ByAltText、ByTitle、ByTestId等。

但这一切都基于一个前提:被测元素必须直接存在于 story 渲染出的普通 DOM 中。当组件渲染器为web-components时,组件内部元素往往位于 shadow root 之下,上述查询在默认的canvas范围内"看不见"它们,findByRole一类查询会因找不到目标而抛错。

针对这一场景,Storybook 官方文档专门提供了一段限定于web-components渲染器的"Querying within shadow DOM"指引(见 docs/writing-tests/interaction-testing.mdx 第 74-89 行)。其核心方案是借助shadow-dom-testing-library,它提供了一整套可"穿透"阴影边界的同构查询版本,例如:

  • findByRole→findByShadowRole
  • getByText→getByShadowText

以此类推,几乎每种 Testing Library 查询都对应一个带Shadow的跨边界版本。

第一步:在.storybook/preview.*中为canvas注入 Shadow DOM 查询能力

要让这些查询在play函数的canvas上可用,需要先在预览配置文件.storybook/preview.*中做一次性全局配置。官方代码片段见 docs/_snippets/shadow-dom-testing-library-in-preview.md。核心思路是:在 preview 的beforeEach钩子中,用shadow-dom-testing-library导出的within函数对每个 story 的canvasElement建立跨边界查询范围,再把这些方法合并(Object.assign)到传入的canvas对象上。

CSF 3 格式的 TypeScript 写法(.storybook/preview.ts)

import type { Preview } from '@storybook/web-components-vite'; import { within as withinShadow } from 'shadow-dom-testing-library'; const preview: Preview = { // 👇 Augment the canvas with the shadow DOM queries beforeEach({ canvasElement, canvas }) { Object.assign(canvas, { ...withinShadow(canvasElement) }); }, // ... }; // 👇 Extend TypeScript types for safety export type ShadowQueries = ReturnType<typeof withinShadow>; // Since Storybook@8.6 declare module 'storybook/internal/csf' { interface Canvas extends ShadowQueries {} } export default preview;

CSF 3 格式的 JavaScript 写法(.storybook/preview.js)

import { within as withinShadow } from 'shadow-dom-testing-library'; export default { // 👇 Augment the canvas with the shadow DOM queries beforeEach({ canvasElement, canvas }) { Object.assign(canvas, { ...withinShadow(canvasElement) }); }, // ... };

CSF Next(实验性)格式:definePreview

在实验性的 CSF Next 规范下,preview 文件改用definePreview定义,其余注入逻辑完全一致:

import { definePreview } from '@storybook/web-components-vite'; import { within as withinShadow } from 'shadow-dom-testing-library'; // 👇 Extend TypeScript types for safety export type ShadowQueries = ReturnType<typeof withinShadow>; // Since Storybook@8.6 declare module 'storybook/internal/csf' { interface Canvas extends ShadowQueries {} } export default definePreview({ // 👇 Augment the canvas with the shadow DOM queries beforeEach({ canvasElement, canvas }) { Object.assign(canvas, { ...withinShadow(canvasElement) }); }, // ... });

对应的 JavaScript 版本将definePreview从@storybook/web-components-vite导入,并直接传入配置对象即可:

import { definePreview } from '@storybook/web-components-vite'; import { within as withinShadow } from 'shadow-dom-testing-library'; export default definePreview({ // 👇 Augment the canvas with the shadow DOM queries beforeEach({ canvasElement, canvas }) { Object.assign(canvas, { ...withinShadow(canvasElement) }); }, // ... });

两个值得注意的实现细节

1. 为什么用beforeEach而非模块顶层代码。在 docs/writing-tests/interaction-testing.mdx 关于 preview 钩子的说明中,beforeEach会在每个 story 渲染前执行,天然适合做"为每次测试重置/增强查询能力"这类工作。由于withinShadow(canvasElement)需要拿到当前 story 的真实canvasElement,该操作必须在钩子内惰性执行,而不是在模块加载时执行一次。

2. TypeScript 类型增强的版本前提。注释// Since Storybook@8.6表明:从 Storybook 8.6 起,可以通过declare module 'storybook/internal/csf'并扩展其中的Canvas接口,把withinShadow的返回类型合并到Canvas上,从而让canvas.findByShadowRole(...)这类调用在编辑器中获得完整类型提示。export type ShadowQueries = ReturnType<typeof withinShadow>则先把查询方法集合声明为可复用类型,供模块增强引用。

第二步:在 story 的play函数中使用 Shadow DOM 查询

完成 preview 配置后,play函数中的canvas便同时具备普通查询与 Shadow DOM 查询两套方法。官方代码片段见 docs/_snippets/shadow-dom-testing-library-in-story.md。下方示例查找的按钮即使位于某个 shadow root 内部,也能被准确定位:

CSF 3 · TypeScript(Example.stories.ts)

export const ShadowDOMExample: Story = { async play({ canvas }) { // 👇 Will find an element even if it's within a shadow root const button = await canvas.findByShadowRole('button', { name: /Reset/i }); }, };

CSF 3 · JavaScript(Example.stories.js)

export const ShadowDOMExample = { async play({ canvas }) { // 👇 Will find an element even if it's within a shadow root const button = await canvas.findByShadowRole('button', { name: /Reset/i }); }, };

CSF Next(实验性)· JavaScript

export const ShadowDOMExample = meta.story({ async play({ canvas }) { // 👇 Will find an element even if it's within a shadow root const button = await canvas.findByShadowRole('button', { name: /Reset/i }); }, });

CSF Next(实验性)· TypeScript

export const ShadowDOMExample = meta.story({ async play({ canvas }) { // 👇 Will find an element even if it's within a shadow root const button = await canvas.findByShadowRole('button', { name: /Reset/i }); }, });

关键 API 语义拆解

findByShadowRole与普通findByRole的差异

由代码可见,Shadow 版查询与 Testing Library 常规查询的签名完全兼容,区别仅在于内部会跨 shadow 边界递归查找:

  • findByShadowRole('button', { name: /Reset/i }):查找可访问角色为button、可访问名称匹配正则/Reset/i的元素——正则写法保证了大小写不敏感匹配,与组件内的 "Reset" 文本对应;
  • 该查询以findBy前缀开头,属于Awaited查询(见前述查询类型表),因此示例中使用await等待元素出现,避免组件异步渲染造成时序问题。

从代码结构看,命名规则保持一致的可推断映射包括:getByText→getByShadowText、getByRole→getByShadowRole、queryBy*/getAllBy*/findAllBy*等亦有对应 Shadow 版本,可覆盖查询类型表中全部 6 类查询(getBy/queryBy/findBy 与单数/复数变体)。

CSF 3 与 CSF Next 的写法差异

同一功能在代码片段中提供了两种规范形态:

  • CSF 3:story 以具名导出的对象字面量定义(TypeScript 版标注为Story类型),如export const ShadowDOMExample: Story = { async play(...) {...} };
  • CSF Next(🧪 实验性):story 通过meta.story({ ... })工厂方法创建,preview 配置侧则对应使用definePreview。

两种形态下play({ canvas })的参数结构与 Shadow DOM 查询用法完全一致,若组件库正处于向 CSF Next 迁移的过程,可据此平滑切换。

组合userEvent与expect完成完整交互测试

查询到 shadow root 内的元素后,就可以把它作为userEvent与expect的目标,构成完整的交互测试闭环。以交互测试文档的约定为准:userEvent的方法(如click、type、keyboard)必须始终await,以确保能在 Interactions 面板中被正确记录与调试;同样,expect断言也建议await,使断言结果可被面板捕获。一个扩展示例如下:

export const ResetFlow: Story = { async play({ canvas }) { const button = await canvas.findByShadowRole('button', { name: /Reset/i }); await expect(button).toBeInTheDocument(); // 例如:清空表单后再验证按钮状态变化 await userEvent.click(button); await expect(canvas.getByShadowText('Form has been reset')).toBeInTheDocument(); }, };

这里userEvent与expect均按 Storybook 交互测试规范,分别来自play函数上下文与storybook/test模块。

适用范围与运行验证

需要明确的是,这段 Shadow DOM 查询能力在官方文档中被<If renderer="web-components">条件包裹(见 docs/writing-tests/interaction-testing.mdx 第 74-89 行),即主要针对web-components渲染器场景编写。如果你的项目基于@storybook/web-components-vite(或同类 Web Components 渲染器)开发、且组件内部使用原生 shadow root 或 Web Components 封装技术(如 Lit),上述方案即可直接套用;对于普通 React/Vue/Svelte 组件,通常不存在跨 shadow root 查询的需求。

运行验证方面,这些play函数内的交互测试可以在 Storybook UI 的 Interactions 面板中预览与调试,也可通过 Vitest addon(@storybook/addon-vitest或storybook/testrunner)在终端与 CI 中自动化执行——这正是交互测试文档所描述的"在 Storybook、终端或 CI 三种环境复用同一套测试"的工作方式。若配置正确但查询仍找不到元素,可优先确认两点:.storybook/preview.*的beforeEach是否已生效、被测组件是否确实将目标 DOM 置于 shadow root 之内。

参考资源

  • 交互测试官方文档(含 Shadow DOM 小节):docs/writing-tests/interaction-testing.mdx
  • preview 全局配置代码片段:docs/_snippets/shadow-dom-testing-library-in-preview.md
  • story 内使用代码片段(本文核心):docs/_snippets/shadow-dom-testing-library-in-story.md
  • play函数入门:docs/writing-stories/play-function.mdx

【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询