Storybook 跨 Shadow DOM 查询实战:基于 shadow-dom-testing-library 编写 Web Components 交互测试
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形式为typesubject。Testing Library 的单元素查询分为三类Type of Query0 Matches1 Match1 MatchesAwaitedgetBy...Throw errorReturn elementThrow errorNoqueryBy...ReturnnullReturn elementThrow errorNofindBy...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→findByShadowRolegetByText→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.tsimport 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 ReturnTypetypeof withinShadow; // Since Storybook8.6 declare module storybook/internal/csf { interface Canvas extends ShadowQueries {} } export default preview;CSF 3 格式的 JavaScript 写法.storybook/preview.jsimport { 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 ReturnTypetypeof withinShadow; // Since Storybook8.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 Storybook8.6表明从 Storybook 8.6 起可以通过declare module storybook/internal/csf并扩展其中的Canvas接口把withinShadow的返回类型合并到Canvas上从而让canvas.findByShadowRole(...)这类调用在编辑器中获得完整类型提示。export type ShadowQueries ReturnTypetypeof withinShadow则先把查询方法集合声明为可复用类型供模块增强引用。第二步在 story 的play函数中使用 Shadow DOM 查询完成 preview 配置后play函数中的canvas便同时具备普通查询与 Shadow DOM 查询两套方法。官方代码片段见 docs/_snippets/shadow-dom-testing-library-in-story.md。下方示例查找的按钮即使位于某个 shadow root 内部也能被准确定位CSF 3 · TypeScriptExample.stories.tsexport const ShadowDOMExample: Story { async play({ canvas }) { // Will find an element even if its within a shadow root const button await canvas.findByShadowRole(button, { name: /Reset/i }); }, };CSF 3 · JavaScriptExample.stories.jsexport const ShadowDOMExample { async play({ canvas }) { // Will find an element even if its within a shadow root const button await canvas.findByShadowRole(button, { name: /Reset/i }); }, };CSF Next实验性· JavaScriptexport const ShadowDOMExample meta.story({ async play({ canvas }) { // Will find an element even if its within a shadow root const button await canvas.findByShadowRole(button, { name: /Reset/i }); }, });CSF Next实验性· TypeScriptexport const ShadowDOMExample meta.story({ async play({ canvas }) { // Will find an element even if its 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 3story 以具名导出的对象字面量定义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 rendererweb-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 addonstorybook/addon-vitest或storybook/testrunner在终端与 CI 中自动化执行——这正是交互测试文档所描述的在 Storybook、终端或 CI 三种环境复用同一套测试的工作方式。若配置正确但查询仍找不到元素可优先确认两点.storybook/preview.*的beforeEach是否已生效、被测组件是否确实将目标 DOM 置于 shadow root 之内。参考资源交互测试官方文档含 Shadow DOM 小节docs/writing-tests/interaction-testing.mdxpreview 全局配置代码片段docs/_snippets/shadow-dom-testing-library-in-preview.mdstory 内使用代码片段本文核心docs/_snippets/shadow-dom-testing-library-in-story.mdplay函数入门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),仅供参考