《Vue 组件测试入门:Vitest 与 Vue Test Utils》
边界先画清楚
本文做两层:组件层(Vue Test Utils 渲染真实组件、断言 DOM 与事件)和组合式函数层(直接调 composable 断言状态)。不做 E2E——那是 Playwright 的地盘,本文一个字不碰;不测视觉效果;Pinia 也不涉及,因为靶子项目是 Vue 实战:书签管理器,那篇是手写 composable 管状态。示例全部围绕它的书签组件展开:添加网址与标题、关键词过滤、标记已读、localStorage 持久化,组件拆成 BookmarkList / BookmarkItem / BookmarkForm 三件,三件本文都测到。
装环境,两分钟的事
npm install -D vitest @vue/test-utils jsdom
配置文件把 Vue 插件和 DOM 环境配进去——组件测试要 document,Node 默认没有:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
},
});
版本口径写清楚:Vue 3.5.x、@vue/test-utils 2.4.6(2.x 稳定线)、Vitest 3.1.x。jsdom 换 happy-dom 也行,选一个别混用——两者对 localStorage、布局 API 的模拟程度不同,混着写会出现”换环境就挂”的用例。
mount 还是 shallowMount
这是 Vue Test Utils 的第一个选择题:mount 把子组件真渲染出来,shallowMount 把所有子组件换成桩。我的口径:默认 mount,两种情况才退到 shallowMount——子组件树太重(比如引了编辑器、图表这类第三方组件,桩掉省事),或者被测的恰恰是父子接线而不是子组件内容。
理由很朴素:组件测试的价值在”贴近真实 DOM”。全 shallow 的测试套件测的其实是 props 传递接线图,渲染逻辑一行没碰,出问题时它照样绿。先看 BookmarkList 的用例,mount 真渲染:
// tests/BookmarkList.test.ts
import { mount } from '@vue/test-utils';
import BookmarkList from '../src/components/BookmarkList.vue';
const bookmarks = [
{ title: 'Astro 官方文档', url: 'https://docs.astro.build', read: false },
{ title: 'Node 官方文档', url: 'https://nodejs.org', read: true },
];
it('按 props 渲染出对应数量的书签', () => {
const wrapper = mount(BookmarkList, { props: { bookmarks } });
expect(wrapper.findAll('li.bookmark')).toHaveLength(2);
});
it('已读的书签带 is-read 类名', () => {
const wrapper = mount(BookmarkList, { props: { bookmarks } });
expect(wrapper.findAll('li.bookmark')[1].classes()).toContain('is-read');
});
两个方向还能微调。shallowMount 之后反悔想要某个真子组件,不用换回 mount,stubs 里单独放行:
const wrapper = shallowMount(BookmarkList, {
props: { bookmarks },
global: { stubs: { BookmarkItem: false } }, // false = 不打桩
});
反过来 mount 里把重依赖子组件打桩也对称:stubs: { RichEditor: true }。方向记反了会排查半天——看到 DOM 里出现 bookmark-item-stub 这种带 stub 后缀的标签,就该想到 stubs 配置。
BookmarkItem:props 进,emits 出
叶子组件的测试最好写,因为它只有一条数据流:props 进来,事件发出去。把实战篇里的组件节选出来:
// src/components/BookmarkItem.vue(节选)
const props = defineProps<{
title: string;
url: string;
read: boolean;
}>();
const emit = defineEmits<{
toggle: [];
remove: [];
}>();
// tests/BookmarkItem.test.ts
import { mount } from '@vue/test-utils';
import BookmarkItem from '../src/components/BookmarkItem.vue';
const base = { title: 'Vue 官方文档', url: 'https://vuejs.org', read: false };
it('标题渲染成链接文本', () => {
const wrapper = mount(BookmarkItem, { props: base });
expect(wrapper.find('a').text()).toBe('Vue 官方文档');
expect(wrapper.find('a').attributes('href')).toBe('https://vuejs.org');
});
it('点删除按钮向上发一次 remove', async () => {
const wrapper = mount(BookmarkItem, { props: base });
await wrapper.find('.bookmark__remove').trigger('click');
expect(wrapper.emitted('remove')).toHaveLength(1);
});
it('点切换按钮发 toggle 且没发 remove', async () => {
const wrapper = mount(BookmarkItem, { props: base });
await wrapper.find('.bookmark__toggle').trigger('click');
expect(wrapper.emitted('toggle')).toHaveLength(1);
expect(wrapper.emitted('remove')).toBeUndefined();
});
wrapper.emitted() 是事件断言的全部:键是事件名,值是每次触发的参数数组,emitted('remove')![0] 就是第一次触发的载荷。第三条”没发 remove”看似多余,我在实战项目里真抓到过一次按钮事件冒泡串线的 bug,就是这种负向断言拦下的。
预期输出(npx vitest run,时长每次不同):
✓ tests/BookmarkList.test.ts (2 tests)
✓ tests/BookmarkItem.test.ts (3 tests)
Test Files 2 passed (2)
Tests 5 passed (5)
BookmarkForm:输入框与提交
表单组件多一步模拟输入。实战篇的表单有两个输入框(class 分别是 title 和 url),提交时向上发带载荷的 add 事件:
// tests/BookmarkForm.test.ts
import { mount } from '@vue/test-utils';
import BookmarkForm from '../src/components/BookmarkForm.vue';
it('填完提交,向上发带载荷的 add', async () => {
const wrapper = mount(BookmarkForm);
await wrapper.find('input.title').setValue('Vue Test Utils 文档');
await wrapper.find('input.url').setValue('https://test-utils.vuejs.org');
await wrapper.find('form').trigger('submit');
expect(wrapper.emitted('add')![0]).toEqual([
{ title: 'Vue Test Utils 文档', url: 'https://test-utils.vuejs.org' },
]);
});
it('URL 为空时不发事件', async () => {
const wrapper = mount(BookmarkForm);
await wrapper.find('input.title').setValue('没有网址的');
await wrapper.find('form').trigger('submit');
expect(wrapper.emitted('add')).toBeUndefined();
});
两个细节值得点破。setValue 模拟的是用户输入——它触发 input 事件,v-model 才收得到;手写 element.value = ... 是没用的,v-model 根本不知道值变了,这是我见新手最多的错。实战篇的 form 用的是 @submit.prevent,测试里不用担心刷新;没 prevent 的表单在 jsdom 里会喊一声 “Not implemented: navigation” 警告,功能不受影响但看着闹心。空值拦截那条对应实战篇”网址必填”的规则,负向断言又赚一条。
nextTick 藏在哪里
Vue 的 DOM 更新是异步批量刷的,改了数据不能立刻断言 DOM。trigger 和 setProps 都返回 Promise,内部已经 await 过一次 nextTick,所以 await trigger('click') 之后断言 DOM 是安全的——这是多数人不知道、文档也没放粗体的点。真正容易漏的是不走 trigger 的路径,比如 setProps 之后看类名变化:
it('read 变 true 后链接加 is-read', async () => {
const wrapper = mount(BookmarkItem, { props: base });
expect(wrapper.find('a').classes()).not.toContain('is-read');
await wrapper.setProps({ read: true });
expect(wrapper.find('a').classes()).toContain('is-read');
});
症状特征很明显:本地跑全绿,CI 的慢机器上偶发挂掉——本地快到 DOM”看起来”恰好刷完了,慢机器上就现形。凡是断言 DOM 的用例,逐个确认中间隔着一个 await 过的 Promise(trigger、setProps 或显式 await nextTick())。
实战篇的关键词过滤输入走 v-model 加 watch,测试里对应写法是 await input.setValue('astro')——setValue 同样返回 Promise。带防抖的 watch 是另一档麻烦:要上假计时器 vi.useFakeTimers() 配 vi.advanceTimersByTime(),本文不展开,先记住有这回事。
find 与 findComponent 的分工
wrapper.find() 吃 CSS 选择器,返回 DOM 元素;wrapper.findComponent() 吃组件定义,返回组件实例。分工原则一句话:断言 DOM、触发事件用 find,只有要检查”某个子组件实例存在与否”或往它身上读 props 时才用 findComponent。常见错误是拿 findComponent 拿到的实例去 trigger——trigger 要的是 DOM 元素,实例上没有这个方法,报错还提示得云里雾里。
it('两条书签各渲染出一个 BookmarkItem 实例', () => {
const wrapper = mount(BookmarkList, { props: { bookmarks } });
expect(wrapper.findAllComponents(BookmarkItem)).toHaveLength(2);
});
findAllComponents 是 findComponent 的复数版,返回组件实例数组。混用两者出的错误,十有八九是”查的层级和想的层级对不上”——li.bookmark 是 BookmarkItem 渲染的,从列表上查要 findAll('li.bookmark'),从组件查要 findAllComponents(BookmarkItem),写之前先想清楚要的是 DOM 还是组件。
调试被测组件时两个函数值回票价:console.log(wrapper.html()) 打印当前渲染出的完整 DOM,断言为什么失败一看便知;本地想逐条看用例,vitest 的 watch 模式配合 tests/BookmarkItem.test.ts 单文件跑,反馈圈最小。
组合式函数:直接调,但要防状态串场
useBookmarks 没碰 onMounted 这类生命周期钩子,可以直接在测试里调用,不用包一层宿主组件。先看它怎么管状态(节选):
// src/composables/useBookmarks.ts(节选)
const items = ref<Bookmark[]>(load()); // 故意放模块顶层:全应用共享一份
const keyword = ref('');
export function useBookmarks() {
const filtered = computed(() =>
items.value.filter((b) =>
b.title.toLowerCase().includes(keyword.value.toLowerCase())
)
);
function add(title: string, url: string) { /* …… */ }
function reset() { items.value = load(); keyword.value = ''; }
return { items, keyword, filtered, add, reset };
}
模块顶层的 ref 在应用里是对的——多组件共享同一份状态。但在测试里它就是个坑:每个用例拿到的是同一份数据,第一个用例 add 的书签会漏进第三个用例的断言。解法是补一个 reset,beforeEach 里清干净:
// tests/useBookmarks.test.ts
import { beforeEach, describe, expect, it } from 'vitest';
import { useBookmarks } from '../src/composables/useBookmarks';
describe('useBookmarks', () => {
beforeEach(() => {
localStorage.clear();
useBookmarks().reset();
});
it('add 之后 keyword 过滤只留命中的', () => {
const { add, keyword, filtered } = useBookmarks();
add('Astro 官方文档', 'https://docs.astro.build');
add('Node 官方文档', 'https://nodejs.org');
keyword.value = 'astro';
expect(filtered.value).toHaveLength(1);
});
it('数据落在 localStorage,刷新后还在', () => {
const { add, items } = useBookmarks();
add('Vue 官方文档', 'https://vuejs.org');
add('Vue 官方文档', 'https://vuejs.org');
expect(items.value).toHaveLength(1); // 重复添加被拦下
expect(JSON.parse(localStorage.getItem('bookmarks') ?? '[]')).toHaveLength(1);
});
});
两个细节。filtered 是 computed,改 keyword.value 之后不需要 nextTick——computed 的重算是同步惰性的,读它的瞬间就算完,nextTick 是渲染层的事,别混。存储键名以实战篇实现为准,这里按 bookmarks 写。第二条例子顺手钉住了实战篇”重复添加去重”的规则,一条用例两份收益。
另一种修法是把状态收回函数体、每次调用 new 一份,需要共享的地方由入口创建一个实例往下传。代价是所有用它的组件都得改签名,迁移成本比加 reset 高——如果模块级单例是有意为之的架构,reset 就是更诚实的方案;别为了好测推翻架构,反过来也一样。
如果哪天 composable 里加了 onMounted 或需要作用域的 watchEffect,直接调用会报错或泄漏订阅,那时再包一层宿主组件或用 effectScope 包住。先直接调,遇到了再升级,别预防性设计。
快照测试:用一次可以,别当主力
Vue Test Utils 原生支持快照:expect(wrapper.html()).toMatchSnapshot() 第一次跑把当前 HTML 存下来,之后每次对比,diff 出变化。我的用法很克制——快照当”回归网”:重构渲染逻辑时跑一遍,逐行确认 diff 都是预期变化再 vitest run -u 更新。它当不了主力断言,因为快照的失败模式是”看一眼就 update”,久而久之退化成无脑批准的复查。给结构复杂、手写断言费劲的组件用;BookmarkItem 这种三行模板,手写断言更值。
还有 inline 变体 toMatchInlineSnapshot(),产物直接写进用例文件,diff 一眼可见,几行模板的小组件用它比另开一个 __snapshots__ 文件直观。注意第一次跑没有快照时会自动写入当前产物——”提交前看一眼 diff”这条纪律必须守住,否则存进版本库的可能是坏产物,之后所有绿都是错的绿。
什么不值得测
- 纯样式与像素位置。jsdom 没有真实布局引擎,getBoundingClientRect 拿回来全是 0,样式问题归视觉回归工具管;
- 第三方库的内部行为。测试它等于替别人维护代码;桩掉,只断言”我们的代码在正确时机调了它”;
- 一行 computed 这类琐碎逻辑。用例本身比被测代码还长,收益是负的,交给类型系统与使用现场;
- 为覆盖率凑数的用例。mount 了不断言、trigger 了不看结果,这种用例给的是假信心,删掉比留着好。
验收清单
npx vitest run全绿,组件层(List/Item/Form)与 composable 层都有用例;- props 断言(链接文本、href)、emits 断言(次数与载荷)、DOM 状态断言(is-read 类名)、输入断言(setValue + add 载荷)四条线各至少一条;
- 故意去掉某个
await trigger的 await,多跑几次或用vitest run --sequence.shuffle打乱顺序,观察偶发失败——确认异步时序的坑被钉住了; - 删掉 beforeEach 里的 reset 重跑,过滤用例应当开始互相污染——确认状态隔离真的生效;
- 临时在 BookmarkForm 里放开空 URL,”URL 为空时不发事件”那条必须红——负向规则有人看守。
接进系列
本文补的是 Vue 学习路线阶段四”本地实战”之后的测试环节:先有能跑的书签管理器,再回头给它织安全网,顺序别倒——没有真实项目支撑的组件测试练习,写完就忘。书签组件的完整结构与设计取舍都在 Vue 实战:书签管理器里,本文所有示例的上下文都能在那篇找到;defineProps 泛型、emitted()、setValue 这类条目速查翻 Vue 参考字典。要继续往深走,下一站是 vi.mock 模块替换与 Pinia store 的测试——store 有 $patch、插件这些测试脚手架要搭,复杂度和本文不在一个量级,值得单独开一篇。E2E 那层等书签管理器接上真实路由再说,届时 Playwright 与冒烟清单是另一篇的事,本文的组件层绿了才轮到它。
本文数据核对时间:2025-05-12:Vue 3.5.x(3.5.0 发布于 2024-09-03,GitHub releases 口径);@vue/test-utils 2.4.6(2024-05-06 发布,2.x 稳定线);Vitest 3.1.x(3.0 GA 2025-01-17);jsdom / happy-dom 不钉精确版本。API 细节以 vuejs.org、test-utils.vuejs.org 与 vitest.dev 官方文档为准。