拓冰建站拓冰建站
首页 / 资讯中心 / 正文

airi 项目中 Vue 3 Teleport 内容测试全攻略:从 Vue Test Utils 失配到 DOM/E2E 验证

airi 项目中 Vue 3 Teleport 内容测试全攻略从 Vue Test Utils 失配到 DOM/E2E 验证【免费下载链接】airi Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-samas altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airiTeleport 是 Vue 3 中将内容渲染到组件 DOM 树之外的官方能力airi 项目大量用它把弹窗、抽屉、预览浮层直接挂载到body下以获得正确的层级与定位。它带来流畅交互的同时也给单测挖了坑Vue Test Utils 的wrapper.find()只查询组件自身的 DOM 树永远看不见被传送出去的内容。本文以 airi 仓库的真实实现为背景梳理 Teleport 内容测试失败的根因并给出Stub 传送门 / 查询 document.body / 定制 Teleport Stub三套可落地的单测方案以及针对内置弹窗组件库和 E2E 场景的完整测试策略。为什么 Teleport 内容会让单测集体翻车Teleport 的语义是渲染到别处。Teleport tobody会把插槽内容挂载到document.body之下而不是挂载组件的容器内。Vue Test Utils 的查询范围却始终以被mount的组件 wrapper 为边界——因此Teleport 之后渲染出来的 DOM天然落在 wrapper 能查到的范围之外wrapper.find()返回空、exists()恒为false测试在毫无报错提示的情况下静默失败。这条规则在 airi 仓库中几乎处处命中因为 Teleport 正是 UI 层的常用手段JournalPreviewModal.vuepackages/stage-ui/src/components/scenarios/chat/JournalPreviewModal.vue第 13 行起用一个Teleport tobody包裹v-if控制的全文/图片预览弹层Live2DReportModal.vue 与 bug-report-dialog.vue、onboarding-dialog.vue 通过 reka-ui 的DialogPortal/ vaul-vue 的DrawerPortal弹出门体这类 Portal 组件底层同样依赖传送机制渲染到bodybackground-removal.vue第 362 行附近把图片预览 tooltip 直接Teleport tobodyindex.vue第 239 行附近把移动端的MobileInteractiveArea整个传送到 body以获得独立于舞台场景容器的交互层io-tracer-chart.vue第 838 行起同样用Teleport tobody承载浮层。因此任何直接对这些组件内部做 DOM 断言的测试都会撞上同一个找不到节点的经典问题。经典失败场景wrapper.find 找不到已存在的弹窗以下Modal.vue是 Teleport 弹窗的最小形态v-if控制显隐内容在body下!-- Modal.vue -- template button clickopen trueOpen/button Teleport tobody div v-ifopen classmodal>// Modal.spec.ts - BROKEN import { mount } from vue/test-utils import Modal from ./Modal.vue test(modal input exists, async () { const wrapper mount(Modal) await wrapper.find(button).trigger(click) // FAILS: Teleported content is not in wrappers DOM tree expect(wrapper.find([data-testidmodal-input]).exists()).toBe(true) })按钮点击、状态更新都正常执行了弹窗也确实被渲染到了document.body下——只是不在wrapper管辖的子树里。测试失败原因与业务逻辑无关纯粹是查询范围错位。airi 仓库为这类单测准备了两条可选技术路径纯 jsdom 环境DOM 仿真见 apps/stage-web/vitest.config.ts 中environment: jsdom的unit项目配合vue/test-utils的mount如 use-transcriptions.test.ts 的用法真实浏览器环境见 packages/stage-ui/vitest.config.ts 与 apps/stage-web/vitest.config.ts 中以 Playwright Chromium 驱动的browser测试项目用vitest-browser-vue的render参见 performance-overlay.browser.test.ts。两条路径对 Teleport 内容的处理方式不同下面逐一给出单测方案。方案一Stub 掉 Teleport让内容留在组件树内适用场景单元测试阶段只关心组件逻辑与内部结构不关心真实挂载位置。在global.stubs中把Teleport置为trueVue Test Utils 会把传送门替换成直接透传渲染的占位组件内容随之留在 wrapper 树内wrapper.find()即可命中。import { mount } from vue/test-utils import Modal from ./Modal.vue test(modal input exists, async () { const wrapper mount(Modal, { global: { stubs: { // Stub teleport to render content inline Teleport: true } } }) await wrapper.find(button).trigger(click) // Works: Content renders inside wrapper expect(wrapper.find([data-testidmodal-input]).exists()).toBe(true) })实现上被 stub 后的 Teleport 行为近似于一个直接渲染默认插槽的组件因此测试关注点从它被渲染到哪转移到它渲染出了什么。它的局限也显而易见无法验证真实的挂载位置也无法覆盖与body相关的样式或层级行为。从实现上看airi 中大量基于v-ifTeleport的浮层例如JournalPreviewModal的模式在逻辑层面都适合用此方案快速覆盖打开/关闭/交互断言而 Portal 型封装DialogPortal等则适合在挂载它们的外层组件测试中一并 stub见下文第四节。方案二保留真实 Teleport查询 document.body适用场景集成测试需要验证弹窗确实传送到了 body、并与真实 DOM 交互。两个关键点缺一不可mount时必须传attachTo: document.body。jsdom 环境下的文档是存在的但若不把 wrapper 附加进文档Teleport 的目标节点解析可能不符合预期导致传送内容无处安放断言必须越过 wrapper直接用document.querySelector或document.body.querySelector查询真实 DOM。import { mount } from vue/test-utils import Modal from ./Modal.vue test(modal renders to body, async () { const wrapper mount(Modal, { attachTo: document.body // Required for Teleport to work }) await wrapper.find(button).trigger(click) // Query the actual DOM const modal document.querySelector([data-testidmodal]) expect(modal).toBeTruthy() const input document.querySelector([data-testidmodal-input]) expect(input).toBeTruthy() // Cleanup wrapper.unmount() })这样既验证了业务逻辑也验证了传送到 body这一真实行为。需要注意每测例结束后必须wrapper.unmount()清理挂到 body 下的节点否则多个用例会互相污染若测试框架环境不提供完整document例如某些 Node 环境此方案不适用应退回方案一或改用浏览器测试。方案三定制 Teleport Stub兼顾结构与可控性如果既想保留内容在 wrapper 内、又想让它落在某个可统一命中的容器中便于批量断言、避免与其他内联内容混淆可以传入一个自定义 Teleport Stub。它渲染一个带固定类名的容器并把默认插槽内容渲染进去import { mount, config } from vue/test-utils import { h, Teleport } from vue import Modal from ./Modal.vue // Custom stub that renders content in a testable way const TeleportStub { setup(props, { slots }) { return () h(div, { class: teleport-stub }, slots.default?.()) } } test(modal with custom stub, async () { const wrapper mount(Modal, { global: { stubs: { Teleport: TeleportStub } } }) await wrapper.find(button).trigger(click) // Content is inside .teleport-stub expect(wrapper.find(.teleport-stub [data-testidmodal-input]).exists()).toBe(true) })若很多测试都要用同一套 Teleport Stub可通过config.global.stubs做全局配置避免每个测试重复声明。此方案的语义更接近传送门仍存在只是目标容器固定为测试容器比方案一的裸truestub 表达力更强也更适合在断言中区分传送内容与组件本体内联内容。补充技巧getComponent() 替代 DOM 查询当断言对象是组件实例而不是DOM 元素时还可以用wrapper.getComponent()按名称/选择器获取已渲染的子组件绕过 DOM 树边界问题。它对内容是否被传送不敏感适合校验传入的 props、读取组件状态或触发组件方法。若你只关心某个内部组件例如弹窗中的表单组件是否按预期挂载与接收参数用getComponent()往往比在 body 上做 DOM 查询更稳定。面对内置 Portal 的 UI 库先理解再 stubairi 及其 Web 端大量使用 reka-uiDialogPortal、DialogRoot等和 vaul-vueDrawerPortal等这类自带传送门的组件库。以 onboarding-dialog.vue 为例桌面端通过DialogRootDialogPortal渲染移动端走DrawerRootDrawerPortal。这些 Portal 内部都执行了类似 Teleport 的挂载策略因此同样会触发wrapper.find 查不到的问题。社区中典型的 Vue Final Modal 案例与此完全同构库内部把弹窗传送到 body导致单测失败。解决方式是 stub 掉库导出的弹窗根组件把渲染范围拉回 wrapper// Problem: Vue Final Modal teleports to body import { VueFinalModal } from vue-final-modal test(modal content, async () { const wrapper mount(MyComponent, { global: { stubs: { // Stub the modal component to avoid teleport issues VueFinalModal: true } } }) })对应到 airi 的实际依赖策略是一样的在测试bug-report-dialog、onboarding-dialog、Live2DReportModal的外层页面/组件时若目标不是验证弹窗本身的渲染细节可以在global.stubs中 stubDialogRoot/DialogContent/DrawerRoot等 Portal 根组件reka-ui、vaul-vue 均声明于 apps/stage-web/package.json 与 packages/ui/package.json 的依赖中让内部内容回到组件树内再做行为断言若测试目标恰恰是弹窗自身的内容与交互则应把它放到浏览器测试或 E2E 中验证真实挂载。浏览器模式与 E2E让 Teleport 回归顺其自然Teleport 在真实浏览器中就是浏览器 DOM 原生的挂载行为因此凡是查询真实 DOM 的测试层级都不存在找不到的问题。这正是 airi 把带样式、层级、原生事件的用例放进 Vitest Browser Mode / Playwright 的根本原因详见同技能组的 testing-browser-vs-node-runners.md。在 E2E 测试Cypress、Playwright中直接写// Cypress it(opens modal, () { cy.visit(/page-with-modal) cy.get(button).click() // Works: Cypress queries the real DOM cy.get([data-testidmodal]).should(be.visible) })airi 的实践与之对应browser 测试项目使用vitest/browser-playwright驱动真实 Chromium见 packages/stage-ui/vitest.config.ts并用vitest-browser-vue的renderscreen.getByRole(...)做基于可访问性语义的查询例如 performance-overlay.browser.test.ts。这种挂在真实 DOM 上再交互的测试对 Teleport 弹窗天然友好——点击按钮后弹窗确实出现在 body 里任何基于文档根节点的查询都能命中。策略速查什么场景用哪套方案测试目标推荐手段关键前提组件内部逻辑打开/关闭/事件stubs: { Teleport: true }不关心挂载位置需要与传送到 body 的真实 DOM 交互attachTo: document.bodydocument.querySelectorjsdom 或真实 DOM 环境记得unmount()清理统一容器断言 / 与内联内容区分自定义 Teleport Stub渲染.teleport-stub容器可结合config.global.stubs复用断言子组件实例而非 DOMwrapper.getComponent()目标是有名/可定位的子组件外层组件挂载了 Portal 型 UI 库stub 库的DialogPortal/VueFinalModal等根组件测试重点不在弹窗自身渲染细节样式、层级、真实浏览器行为Vitest Browser Mode / E2ECypress、Playwright查询真实 DOMTeleport 无需特殊处理综合建议单元测试默认用方案一或方案三保持快速与隔离需要验证确实挂到了 body时切换到方案二涉及真实布局、层级与原生交互时升级到浏览器级测试。以 airi 的测试基建jsdom unit 项目 Playwright browser 项目并存来看这一策略恰好能覆盖从纯逻辑到真实渲染的完整测试金字塔。参考与延伸本文核心策略的规范出处teleport-testing-complexity.mdvue-testing-best-practices 技能组技能导航与相关 gotchaSKILL.md浏览器级与 Node 级测试运行器选型testing-browser-vs-node-runners.mdairi 真实 Teleport 用例JournalPreviewModal.vue、background-removal.vue、index.vueairi 使用 Portal 型 UI 库的弹窗onboarding-dialog.vue、bug-report-dialog.vue、Live2DReportModal.vueairi 双轨测试配置packages/stage-ui/vitest.config.ts、apps/stage-web/vitest.config.tsvue/test-utils在该仓库的用法示例use-transcriptions.test.ts浏览器组件测试示例performance-overlay.browser.test.ts【免费下载链接】airi Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-samas altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门