最近给一个维护了大半年的中后台系统补单元测试我发现在团队里搜“Vit常见编码方式”翻出来的测试代码至少有四五种风格有人mount一把梭有人万事shallowMount有人测异步靠sleep(2000)还有人把组件内部的状态管理函数全都 mock 掉。这些测试在写完的那一刻全是绿的等到产品改需求红起来也是整片整片地红。先把工具链说清楚。标题里的 Vit在我经历的项目里通常指 Vitest它是基于 Vite 的测试框架而“常见编码方式”的另一个主角是vue/test-utils也就是 Vue 的官方测试工具库负责组件的挂载、交互和断言。大家平时说 Vit 测试其实默认就是这两者搭配使用。这篇文章围绕 Vue 3 组件测试的常见编码方式展开我会结合具体代码讲明白挂载、props、事件、v-model、异步和 mock 到底怎么写才稳定适合刚接触 Vue 单测、或者写过一些但总是不稳定的同学参考。你不需要一次性读完把它当成排查测试问题时的备查手册更合适。1. 先厘清环境Vitest 与 Vue Test Utils 在测试里各管什么1.1 两者的职责边界Vitest 负责提供describe、it、expect、mock、fake timers、覆盖率这些基础设施vue/test-utils负责把组件实例化生成一个包装器wrapper让你能查询 DOM、触发事件、断言 props 和 emits。简单说Vitest 是“考试环境”Vue Test Utils 是“答题卡上的操作工具”。很多新手容易把两者混着用。比如想断言组件的响应式更新不通过 wrapper 去查 DOM反而直接在外头操纵组件实例。Vue 3 里setup的状态对外是封闭的外部拿不到内部ref硬要去拿实例操作只会让测试和实现严重耦合。更合理的编码方式是组件内部状态变化一律通过 DOM 表现来断言组件对外通信一律通过emitted()事件来断言组件输入一律通过 props 来驱动。我举个例子一个表单组件在输入非法邮箱时内部会往errors里塞一条提示。测试时不需要访问errors只需要断言页面上的错误文本有没有出现即可。这样测试不关心错误存在哪个字段里只关心用户看没看到提示。这一点是后面所有编码方式的前提一旦这个边界乱了测试一定会越写越痛苦。1.2 最小配置里容易被忽略的三个开关Vitest 可以直接读取 Vite 配置文件也可以用独立的vitest.config.ts。下面是一个我在实际项目中常用来起步的配置很多团队都在这份配置上栽过跟头/// reference typesvitest / import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], test: { environment: jsdom, globals: true, css: false, setupFiles: [./test/setup.ts], clearMocks: true, restoreMocks: true } })其中三个开关是新手最容易忽略的。第一个是environment: jsdom。Vue 组件测试需要 DOM 环境默认的 node 环境里没有document、window只要组件模板一挂载就会报错。如果你发现mount刚执行就抛document is not defined基本都是这个配置没写。第二个是css: false。不关掉 CSS 处理的话测试启动时还要去解析样式文件遇到一些 CSS 新特性还会直接报错。组件测试关注的是逻辑和结构样式交给浏览器和视觉回归去管配置里关掉是合理的。第三个是setupFiles。这个文件会在每个测试文件执行前运行一次适合注册全局组件、mock 少量原生 API。我在团队里看到的最大坑是有人把“修改全局状态”的逻辑写进了 setup 文件结果测试文件之间互相污染跑单文件全绿跑全量随机红。setup 里只放公共且“只读”的内容动态状态一律在beforeEach/afterEach里管理。1.3 一个完整的 setup.ts 雏形import { config } from vue/test-utils import { vi } from vitest config.global.stubs { transition: false } // 某些浏览器 API 在 jsdom 里不存在统一 mock if (!window.matchMedia) { window.matchMedia vi.fn().mockImplementation((query) ({ matches: false, media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn() })) }这里把Transition组件 stub 掉是一个经验活。Vue 内置的Transition在单测里经常带来不稳定行为比如进入动画的状态没走完导致断言时机不对。把它 stub 成false可以直接关闭过渡渲染测试就干净很多。2. 挂载方式定生死mount、shallowMount 与 stub 的选择2.1 从一次“改子组件导致父组件测试崩掉”说起有次我维护一个列表页组件里面渲染了一个性能很重的子组件专门做长文本展示。父组件的测试用mount全量挂载断言的是列表头几个字段是否渲染。后来子组件内部优化把某个根节点类名改了父组件的测试直接红了。当时排查了半天发现父组件逻辑一行没动纯粹是被子组件的实现细节拖下水。这个问题的本质是mount会渲染整棵真实组件树子孙组件里的任何 DOM 变化都会成为父组件测试的噪声。单元测试追求的是“当前组件行为”的确定性而不是整棵组件树的快照。从那以后我大多改用shallowMount它会自动把除当前组件以外的所有子组件替换为 stub父组件的用例只关心自己这一层的行为。不过也不能矫枉过正。全项目无脑shallowMount也会出问题比如某个操作按钮只有子组件真正渲染出来才能被找到stub 掉之后按钮消失了测试就会写成“假阳性”——断言的对象根本不是用户真实看到的界面。所以挂载方式的选择需要有个判断标准而不是一刀切。2.2 什么时候选 shallowMount什么时候必须 mount我根据踩坑经验整理了一张判断表场景推荐挂载方式原因只验证当前组件自身的渲染逻辑shallowMount隔离子组件噪声速度快子组件之间需要真实交互mount需要真实 DOM 事件和层级一个子组件的行为当前用例不关心shallowMount或手动 stub减少外部依赖子组件会真实影响当前组件的存在性mount例如通过v-if切换子组件验证 slot 内容是否被子组件正确接收mountslot 内容必须真实渲染才能断言核心判断就三步被测用例的断言对象是不是当前组件的对外行为子组件会不会给这个用例带来不确定因素测试速度能不能接受这三步里最容易被忽略的是第二步。很多人想当然用mount结果子组件里发了一个请求、一个定时器父组件测试不仅要 mock 子组件的接口还要处理子组件的生命周期用例越写越长。反过来如果你要测的是一个弹窗的整体交互包括打开、确认、关闭的完整链路那shallowMount把弹窗内部按钮 stub 成了空壳断言自然无从谈起。此时必须mount。2.3 手写 stub 和全局组件的处理方式shallowMount的默认 stub 是一个能接收 slot 的简单元素但在某些情况下默认 stub 不够用。比如项目里用了router-link组件里写的是router-link to/detail查看详情/router-linkshallowMount默认不会帮你把带路由跳转的组件 stub 得很干净比较稳妥的做法是在global.stubs里显式指定import { shallowMount } from vue/test-utils const wrapper shallowMount(ArticleItem, { global: { stubs: { router-link: { template: aslot //a }, HeavyContent: true } } })这里HeavyContent: true表示把它替换成纯占位 stubrouter-link则提供了带 slot 的简化模板让组件内部的链接文案还能正常渲染出来。这样既不影响当前组件对链接文案的断言也不会真的触发路由解析。另一个经验是attachTo。如果组件在onMounted里调用了getBoundingClientRect或者需要监听全局事件单纯的mount可能因为组件没有真正挂到 DOM 文档里而拿不到布局信息。这时可以const wrapper mount(TableFooter, { attachTo: document.body }) // 用例结束后必须清理 wrapper.unmount()attachTo之后组件会真实插入文档影响范围比普通挂载更大所以一定要在用例结束或afterEach里调用unmount否则事件监听和 DOM 节点都会泄漏到下一个用例。这是常被忽略的坑。3. 组件交互的常见编码方式props、事件与 v-model3.1 props 断言与 setProps 的正确姿势props 是组件的输入测试的第一步通常是验证输入能否正确反映到界面上。假设有个徽标组件!-- Badge.vue -- script setup defineProps({ label: { type: String, default: } }) /script template span classbadge{{ label }}/span /template对应的测试代码import { mount } from vue/test-utils import Badge from /components/Badge.vue it(正确渲染 label 并响应 props 更新, async () { const wrapper mount(Badge, { props: { label: new } }) expect(wrapper.find(.badge).text()).toBe(new) await wrapper.setProps({ label: hot }) expect(wrapper.find(.badge).text()).toBe(hot) })这里有两个关键点。第一setProps必须await因为 Vue 的 DOM 更新是异步的不等待就断言可能拿到上一次渲染的结果。第二setProps只能更新挂载时已经传入的 props如果你在setProps里塞一个挂载时根本没传过的 propVue 会把它当作 attribute 直接表现在 DOM 属性上不会变成组件内部逻辑的输入。这两点写测试时经常会踩尤其是第二点看起来没报错但实际上测试根本没驱动到组件内部。3.2 trigger 与 emitted对外事件的测试套路组件的输出除了渲染结果就是事件。测试事件有一个固定套路先通过trigger触发用户操作再用emitted()断言组件派发了什么事件。!-- Counter.vue -- script setup import { ref } from vue const count ref(0) const emit defineEmits([change]) function increment() { count.value 1 emit(change, count.value) } /script template button classinc clickincrement{{ count }}/button /templateimport { mount } from vue/test-utils import Counter from /components/Counter.vue it(点击按钮后更新数字并派发 change 事件, async () { const wrapper mount(Counter) await wrapper.find(.inc).trigger(click) expect(wrapper.text()).toContain(1) expect(wrapper.emitted(change)).toHaveLength(1) expect(wrapper.emitted(change)?.[0]).toEqual([1]) })emitted()返回的对象结构是两层数组外层数组的每一项对应一次事件触发内层数组是那次触发时传入的参数。所以emitted(change)?.[0]拿到的是第一次触发的事件参数列表toEqual([1])表示第一次change事件携带的参数是1。这个结构刚接触时容易绕晕但记住“外层次数、内层参数”就够了。trigger也可以传入第二个参数用来模拟事件对象里的字段。比如组件内部读取了event.clientX可以这样写await wrapper.find(.inc).trigger(click, { clientX: 100 })这个能力在测试拖拽、点击坐标等场景里非常有用。3.3 v-model 双向绑定的三种写法自定义组件和外部v-model通信是组件测试里容易出错的点。Vue 3.4 之后推荐用defineModel来简化写法假设有个搜索输入框!-- SearchInput.vue -- script setup const model defineModel() /script template input v-modelmodel>import { mount } from vue/test-utils import SearchInput from /components/SearchInput.vue it(输入内容时向外派发 update:modelValue, async () { const wrapper mount(SearchInput, { props: { modelValue: 初始值 } }) await wrapper.get([data-testsearch]).setValue(vue) expect(wrapper.emitted(update:modelValue)?.[0]).toEqual([vue]) })第二层验证外部更新modelValue时视图能同步刷新it(外部更新 modelValue 时输入框能同步, async () { const wrapper mount(SearchInput, { props: { modelValue: 初始值 } }) await wrapper.setProps({ modelValue: 新值 }) const input wrapper.get([data-testsearch]).element as HTMLInputElement expect(input.value).toBe(新值) })如果项目里还在用 Vue 3.3 或更早版本defineModel不可用通常会写update:modelValue事件和props.modelValue的组合。测试代码不需要变核心断言仍然是两层输入时发事件、外部刷新时视图同步。记住这一个原则无论组件内部写法怎么变化测试编码方式都是通用的。4. 异步与 Mock 的编码套路定时器、Promise 与远程接口4.1 nextTick、flushPromises 与 fake timers 如何选异步是 Vue 组件测试里最让人头疼的部分。组件里常见的异步场景有三种Vue 自身渲染更新、Promise 链上的数据回调、setTimeout/setInterval定时器。这三种等待方式不能乱用我用一个表格简单对比一下场景等待手段说明普通 DOM 更新await nextTick()只等一个 Vue 更新周期await某个接口后的数据渲染flushPromises把当前微任务队列里的 Promise 回调都执行完setTimeout / setIntervalvi.useFakeTimers() 时间推进模拟时间流逝而不是真等很多人误以为nextTick能解决所有异步问题。实际上如果组件在onMounted里发了一个await fetchData()接下来赋值的ref才触发视图更新单靠一个nextTick是不够的。因为nextTick只是等当前响应式更新队列不等接口返回。碰到这种情况我常用一个辅助函数const flushPromises () new Promise((resolve) setTimeout(resolve, 0))这个实现不算严谨但在多数“接口返回后立刻更新 DOM”的场景里够用。更严谨的方案是直接装一个flush-promises包内部实现比手写版本可靠得多。如果项目里不允许随便引第三方测试依赖也可以用vi.waitForVitest 0.34 之后新增它会更贴近“等待某个条件成立”的表达。我的经验是先用flushPromises处理链式 Promise再用await nextTick()处理最后一次视图更新两者结合基本覆盖大部分异步组件场景。4.2 定时器场景的两种模式定时器组件用真实时间等待是最傻的写法一个 10 秒的轮询组件会让测试真实跑 10 秒。正确做法是用 fake timers。import { mount } from vue/test-utils import { vi } from vitest import ProgressTimer from /components/ProgressTimer.vue describe(ProgressTimer, () { afterEach(() { vi.useRealTimers() }) it(倒计时结束后派发 finish 事件, async () { vi.useFakeTimers() const wrapper mount(ProgressTimer, { props: { duration: 3000 } }) await vi.advanceTimersByTimeAsync(3000) expect(wrapper.emitted(finish)).toHaveLength(1) }) })这里必须用advanceTimersByTimeAsync而不是同步版本。原因很隐蔽如果定时器回调里还有await语句同步推进时间只会执行到第一个await之前后续逻辑要到下一个微任务才会执行。advanceTimersByTimeAsync会在每次时间推进后等待微任务队列清空更适合真实的定时器回调。结束之后必须调用vi.useRealTimers()否则后面所有测试文件的时间都会被 fake 掉出现一些完全无法定位的随机失败。这也是 5.3 会展开讲的“测试污染”源头之一。4.3 接口 Mock 的颗粒度mock 模块还是 mock 请求层组件测试里几乎绕不开接口请求。常见的编码方式是在 API 模块边界做 mock也就是把整个接口模块替换掉让被测组件拿到假数据。用 Vitest 的vi.mock有一个提升陷阱先看正确写法const mocks vi.hoisted(() ({ fetchUser: vi.fn() })) vi.mock(/api/user, () ({ fetchUser: mocks.fetchUser })) import { mount } from vue/test-utils import UserCard from /components/UserCard.vue it(卡片展示接口返回的用户名, async () { mocks.fetchUser.mockResolvedValue({ name: Tom, level: 3 }) const wrapper mount(UserCard) await flushPromises() expect(wrapper.text()).toContain(Tom) })vi.mock的工厂函数会被提升到文件顶部执行这意味着你在文件中部声明const mocks ...工厂函数里引用它时该变量还没有初始化直接报ReferenceError。vi.hoisted就是专门解决这个问题的机制它把变量声明提升到所有 mock 之前。很多新手写vi.mock遇到变量引用报错第一反应是调整 import 顺序其实问题根源是提升。Mock 的颗粒度建议放在 API 模块层不要深入 mock 组件内部的工具函数。比如组件里有个纯函数formatMoney你为了“方便测试”把它 mock 成固定返回“100”那测试的实际效果就很弱格式化逻辑改坏了测试照样绿。正确的思路是断言组件最终对外呈现的行为时被测组件内部的关键逻辑必须真实跑一遍只有当前用例不关心的第三方依赖如路由、全局组件、浏览器 API才值得 mock。4.4 定时器与 Promise 混合时的一个坑有个组件在定时器回调里做了两步操作先更新一个ref再调用另一个异步方法。我一开始写的测试是这样的vi.useFakeTimers() const wrapper mount(NotifyTimer) vi.advanceTimersByTime(3000) expect(wrapper.emitted(notify)).toHaveLength(1)结果断言经常失败有时候又偶发通过。问题出在advanceTimersByTime是同步的它把定时器回调执行完之后回调里的await还没结算事件自然没发出来。改为await vi.advanceTimersByTimeAsync(3000)之后测试就稳定了。这个坑排查起来很折磨人因为偶发绿、偶发红很难怀疑到同步异步混合上。我的建议是只要定时器回调里出现过await、.then()或Promise.resolve()一律用advanceTimersByTimeAsync不要赌同步执行顺序。5. 不稳定测试的排查实录从“全绿”到“偶尔红”5.1 反模式一事件触发后不 await断言全靠运气我见过最多的问题写法是把trigger当成同步方法wrapper.find(button).trigger(click) expect(wrapper.text()).toContain(1)trigger返回的是一个 Promise内部包含了 Vue 的异步更新流程。不await的话断言执行时机不确定。当次点击后的 DOM 更新如果刚好在断言前完成测试就是绿的一旦事件队列顺序稍有变化就会红。这个“全绿”到“偶尔红”的切换没有任何逻辑上的警告最伤代码库信任感。清洗写法很简单await wrapper.find(button).trigger(click) expect(wrapper.text()).toContain(1)规则也简单凡是和 Vue 更新、DOM 操作、事件触发相关的断言前面一律带上await。这条规则比任何“深刻理解 Vue 更新机制”的建议都实用。5.2 反模式二过度 mock 掉被测对象的核心行为另一个不稳定的来源不是代码不稳定而是“测试根本没测到真东西”。有一次排查一个金额格式化组件发现某位同事在测试里把组件内部引入的formatMoney整个 mock 掉了mock 返回值写死为“100”然后断言页面显示“100”。这个用例永远绿但其实是假绿。因为真正的格式化函数可能早就坏掉了测试仍然显示通过。我后来和团队约定了一个判断标准如果某个 mock 是为了“让测试快点通过”而存在的那这个 mock 就值得警惕更能说明问题的是删掉这个 mock 后测试如果仍然能验证组件行为那它就不该存在。提示mock 的合理边界是“你不关心它在真实环境里如何工作的外部依赖”而不是“你懒得处理它返回值的内部逻辑”。5.3 反模式三测试之间共享状态导致的随机失败有阵子跑测试总是偶发失败单独跑一个文件全绿跑完整个项目一定有几个文件红。最后定位到原因非常典型前面某个测试文件用了vi.useFakeTimers()但没调vi.useRealTimers()后续测试文件里的setTimeout全被接管那些依赖真实时间线的组件全部乱了。排查过程里我建立了一套自检清单现在也分享出来每个文件afterEach里是否有vi.useRealTimers()每次vi.fn()的调用记录是否被清除用clearMocks还是restoreMocks测试结束有没有调用wrapper.unmount()用到全局 store、localStorage 时每个用例前是否恢复到初始状态setup 文件里有没有修改可变全局对象其中clearMocks和restoreMocks有个容易混淆的差异。clearMocks只清空 mock 的调用记录不还原被 spy 的方法实现restoreMocks会把被vi.spyOn改写的方法恢复成原始实现。实际项目里我两个都开行为最干净test: { clearMocks: true, restoreMocks: true }5.4 稳定测试的“自检问题清单”最后整理一份我在 code review 时逐条问的清单基本上能套在大多数组件测试上这个用例是否只断言当前组件的对外行为而不是它的内部实现是否存在 mock 掉核心逻辑、导致断言没有意义的假绿异步操作是否都通过await等待完成而不是“猜时机”每个用例之间是否存在共享的全局状态没有清理定时器场景是否用了 fake timers并确保其他文件不受影响断言的内容是否是用户可感知的 DOM 或事件而不是直接读ref内部值这六个问题里前两个决定测试的价值后四个决定测试的稳定性。我把它们沉淀成团队的测试规范之后改造后的测试代码从“偶尔红”变成了“长期绿”遇到回归时定位问题也快了很多。最后再分享一点项目里的实际体会写完这套体系后我对 Vit 的“常见编码方式”有了新的理解它不是一个固定的语法清单而是一套面向行为的测试思维。同样的组件用mount还是shallowMount、先断言 DOM 还是先断言事件、mocking 到哪一层这些选择背后的判断标准比代码本身更重要。我的建议是新项目一开始就确定几条简单的测试约定比如交互类组件必须有 “触发事件 断言 emitted” 的用例有异步请求的组件必须在 API 边界 mock凡是触发操作前一律await。把这些约定写进团队的 review 检查项比等测试红了再补救要划算得多。如果你正好在给 Vue 3 项目补测试先从最容易写坏的两个地方开始改把所有trigger加上await把所有vi.mock的引用变量收进vi.hoisted。这两处稳定了你的测试瞬间会可靠上一大截。
阅读完成 · 觉得有帮助?