airi 项目中 Vue 3 Teleport 内容测试全攻略:从 Vue Test Utils 失配到 DOM/E2E 验证
2026/9/9 20:28:54 网站建设 项目流程

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-sama's altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi

Teleport 是 Vue 3 中将内容渲染到组件 DOM 树之外的官方能力,airi 项目大量用它把弹窗、抽屉、预览浮层直接挂载到body下以获得正确的层级与定位。它带来流畅交互的同时,也给单测挖了坑:Vue Test Utils 的wrapper.find()只查询组件自身的 DOM 树,永远"看不见"被传送出去的内容。本文以 airi 仓库的真实实现为背景,梳理 Teleport 内容测试失败的根因,并给出"Stub 传送门 / 查询 document.body / 定制 Teleport Stub"三套可落地的单测方案,以及针对内置弹窗组件库和 E2E 场景的完整测试策略。

为什么 Teleport 内容会让单测集体翻车

Teleport 的语义是"渲染到别处"。<Teleport to="body">会把插槽内容挂载到document.body之下,而不是挂载组件的容器内。Vue Test Utils 的查询范围却始终以被mount的组件 wrapper 为边界——因此Teleport 之后渲染出来的 DOM,天然落在 wrapper 能查到的范围之外wrapper.find()返回空、exists()恒为false,测试在毫无报错提示的情况下"静默失败"。

这条规则在 airi 仓库中几乎处处命中,因为 Teleport 正是 UI 层的常用手段:

  • JournalPreviewModal.vue(packages/stage-ui/src/components/scenarios/chat/JournalPreviewModal.vue第 13 行起)用一个<Teleport to="body">包裹v-if控制的全文/图片预览弹层;
  • Live2DReportModal.vue 与 bug-report-dialog.vue、onboarding-dialog.vue 通过 reka-ui 的DialogPortal/ vaul-vue 的DrawerPortal弹出门体(这类 Portal 组件底层同样依赖传送机制渲染到body);
  • background-removal.vue(第 362 行附近)把图片预览 tooltip 直接Teleport to="body"
  • index.vue(第 239 行附近)把移动端的MobileInteractiveArea整个传送到 body,以获得独立于舞台场景容器的交互层;
  • io-tracer-chart.vue(第 838 行起)同样用<Teleport to="body">承载浮层。

因此,任何直接对这些组件内部做 DOM 断言的测试,都会撞上同一个"找不到节点"的经典问题。

经典失败场景:wrapper.find 找不到"已存在"的弹窗

以下Modal.vue是 Teleport 弹窗的最小形态(v-if控制显隐,内容在body下):

<!-- Modal.vue --> <template> <button @click="open = true">Open</button> <Teleport to="body"> <div v-if="open" class="modal">// 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 wrapper's DOM tree expect(wrapper.find('[data-testid="modal-input"]').exists()).toBe(true) })

按钮点击、状态更新都正常执行了,弹窗也确实被渲染到了document.body下——只是不在wrapper管辖的子树里。测试失败原因与业务逻辑无关,纯粹是"查询范围错位"。airi 仓库为这类单测准备了两条可选技术路径:

  • 纯 jsdom 环境(DOM 仿真):见 apps/stage-web/vitest.config.ts 中environment: 'jsdom'unit项目,配合@vue/test-utilsmount(如 use-transcriptions.test.ts 的用法);
  • 真实浏览器环境:见 packages/stage-ui/vitest.config.ts 与 apps/stage-web/vitest.config.ts 中以 Playwright + Chromium 驱动的browser测试项目(用vitest-browser-vuerender,参见 performance-overlay.browser.test.ts)。

两条路径对 Teleport 内容的处理方式不同,下面逐一给出单测方案。

方案一:Stub 掉 Teleport,让内容留在组件树内

适用场景:单元测试阶段只关心组件逻辑与内部结构,不关心真实挂载位置。global.stubs中把Teleport置为true,Vue 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-testid="modal-input"]').exists()).toBe(true) })

实现上,被 stub 后的 Teleport 行为近似于一个直接渲染默认插槽的组件,因此测试关注点从"它被渲染到哪"转移到"它渲染出了什么"。它的局限也显而易见:无法验证真实的挂载位置,也无法覆盖与body相关的样式或层级行为。

从实现上看,airi 中大量基于v-if+Teleport的浮层(例如JournalPreviewModal的模式)在逻辑层面都适合用此方案快速覆盖"打开/关闭/交互"断言;而 Portal 型封装(DialogPortal等)则适合在挂载它们的外层组件测试中一并 stub(见下文第四节)。

方案二:保留真实 Teleport,查询 document.body

适用场景:集成测试,需要验证弹窗确实传送到了 body、并与真实 DOM 交互。

两个关键点缺一不可:

  1. mount时必须传attachTo: document.body。jsdom 环境下的文档是存在的,但若不把 wrapper 附加进文档,Teleport 的目标节点解析可能不符合预期,导致传送内容"无处安放";
  2. 断言必须越过 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-testid="modal"]') expect(modal).toBeTruthy() const input = document.querySelector('[data-testid="modal-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-testid="modal-input"]').exists()).toBe(true) })

若很多测试都要用同一套 Teleport Stub,可通过config.global.stubs做全局配置,避免每个测试重复声明。此方案的语义更接近"传送门仍存在,只是目标容器固定为测试容器",比方案一的裸truestub 表达力更强,也更适合在断言中区分传送内容与组件本体内联内容。

补充技巧:getComponent() 替代 DOM 查询

当断言对象是"组件实例"而不是"DOM 元素"时,还可以用wrapper.getComponent()按名称/选择器获取已渲染的子组件,绕过 DOM 树边界问题。它对内容是否被传送不敏感,适合校验传入的 props、读取组件状态或触发组件方法。若你只关心某个内部组件(例如弹窗中的表单组件)是否按预期挂载与接收参数,用getComponent()往往比在 body 上做 DOM 查询更稳定。

面对内置 Portal 的 UI 库:先理解,再 stub

airi 及其 Web 端大量使用 reka-ui(DialogPortalDialogRoot等)和 vaul-vue(DrawerPortal等)这类"自带传送门"的组件库。以 onboarding-dialog.vue 为例,桌面端通过<DialogRoot>+<DialogPortal>渲染,移动端走<DrawerRoot>+<DrawerPortal>。这些 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-dialogonboarding-dialogLive2DReportModal的外层页面/组件时,若目标不是验证弹窗本身的渲染细节,可以在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-testid="modal"]').should('be.visible') })

airi 的实践与之对应:browser 测试项目使用@vitest/browser-playwright驱动真实 Chromium(见 packages/stage-ui/vitest.config.ts),并用vitest-browser-vuerender+screen.getByRole(...)做基于可访问性语义的查询,例如 performance-overlay.browser.test.ts。这种"挂在真实 DOM 上再交互"的测试对 Teleport 弹窗天然友好——点击按钮后,弹窗确实出现在 body 里,任何基于文档根节点的查询都能命中。

策略速查:什么场景用哪套方案

测试目标推荐手段关键前提
组件内部逻辑(打开/关闭/事件)stubs: { Teleport: true }不关心挂载位置
需要与传送到 body 的真实 DOM 交互attachTo: document.body+document.querySelectorjsdom 或真实 DOM 环境,记得unmount()清理
统一容器断言 / 与内联内容区分自定义 Teleport Stub(渲染.teleport-stub容器)可结合config.global.stubs复用
断言子组件实例而非 DOMwrapper.getComponent()目标是有名/可定位的子组件
外层组件挂载了 Portal 型 UI 库stub 库的DialogPortal/VueFinalModal等根组件测试重点不在弹窗自身渲染细节
样式、层级、真实浏览器行为Vitest Browser Mode / E2E(Cypress、Playwright)查询真实 DOM,Teleport 无需特殊处理

综合建议:单元测试默认用方案一或方案三保持快速与隔离;需要验证"确实挂到了 body"时切换到方案二;涉及真实布局、层级与原生交互时升级到浏览器级测试。以 airi 的测试基建(jsdom unit 项目 + Playwright browser 项目并存)来看,这一策略恰好能覆盖从纯逻辑到真实渲染的完整测试金字塔。

参考与延伸

  • 本文核心策略的规范出处:teleport-testing-complexity.md(vue-testing-best-practices 技能组)
  • 技能导航与相关 gotcha:SKILL.md
  • 浏览器级与 Node 级测试运行器选型:testing-browser-vs-node-runners.md
  • airi 真实 Teleport 用例:JournalPreviewModal.vue、background-removal.vue、index.vue
  • airi 使用 Portal 型 UI 库的弹窗:onboarding-dialog.vue、bug-report-dialog.vue、Live2DReportModal.vue
  • airi 双轨测试配置:packages/stage-ui/vitest.config.ts、apps/stage-web/vitest.config.ts
  • @vue/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-sama's altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi

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

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

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

立即咨询