Vue Test Utils 组件测试
1. 组件测试 vs 单元测试
上一篇讲了工具函数和 composables 的单测,这篇看组件——有模板、有 props、有事件、有子组件。
Java 对照:
- 工具函数测试 ≈
Service单元测试(纯逻辑) - 组件测试 ≈
MockMvc的 Controller 测试(输入 HTTP 请求,断言响应)
组件测试的三段式:
- 挂载(mount)—— 渲染组件到虚拟 DOM
- 交互(trigger/setValue)—— 模拟点击、输入
- 断言(expect)—— 检查渲染结果、抛出的事件
import { mount } from '@vue/test-utils'
const wrapper = mount(Counter, { props: { initial: 5 } }) // 挂载
await wrapper.find('button').trigger('click') // 交互
expect(wrapper.text()).toContain('6') // 断言
记住这三步,几乎所有组件测试都是这个套路。
2. 挂载方式:mount vs shallowMount
2.1 mount:完整挂载
import { mount } from '@vue/test-utils'
import NoteCard from '@/components/NoteCard.vue'
const wrapper = mount(NoteCard, {
props: { note: { id: 1, title: '测试', summary: '摘要' } },
})
子组件也会真正渲染。适合叶子组件或需要测真实 DOM 结构的场景。
2.2 shallowMount:浅挂载
import { shallowMount } from '@vue/test-utils'
const wrapper = shallowMount(NoteList)
子组件被替换成占位桩(<note-card-stub />),只测当前组件。适合包含大量子组件的容器组件,避免测试变成"连带测所有子组件"。
什么时候用哪个:
- 测独立组件(按钮、卡片、输入框)→
mount - 测容器组件(列表页、表单页)→
shallowMount,避免子组件炸
3. 第一个组件测试
<!-- src/components/Counter.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{ initial?: number }>()
const emit = defineEmits<{ change: [value: number] }>()
const count = ref(props.initial ?? 0)
const increment = () => {
count.value++
emit('change', count.value)
}
</script>
<template>
<div class="counter">
<p class="count">{{ count }}</p>
<button class="btn-inc" @click="increment">+1</button>
</div>
</template>
// src/components/Counter.spec.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
describe('Counter', () => {
it('默认从 0 开始', () => {
const wrapper = mount(Counter)
expect(wrapper.find('.count').text()).toBe('0')
})
it('接受 initial prop', () => {
const wrapper = mount(Counter, { props: { initial: 10 } })
expect(wrapper.find('.count').text()).toBe('10')
})
it('点击按钮加 1', async () => {
const wrapper = mount(Counter, { props: { initial: 5 } })
await wrapper.find('.btn-inc').trigger('click')
expect(wrapper.find('.count').text()).toBe('6')
})
it('点击时抛出 change 事件', async () => {
const wrapper = mount(Counter)
await wrapper.find('.btn-inc').trigger('click')
await wrapper.find('.btn-inc').trigger('click')
const events = wrapper.emitted('change')
expect(events).toHaveLength(2)
expect(events![0]).toEqual([1])
expect(events![1]).toEqual([2])
})
})
为什么大量 await:Vue 的 DOM 更新是异步的,trigger 后要等下一个 tick 才能断言。await trigger(...) 内部已经处理好了。
4. 查找元素的 API
// 单个元素
wrapper.find('.selector') // CSS 选择器
wrapper.find('[data-test="xx"]') // data 属性
wrapper.find(ChildComponent) // 子组件
// 多个元素
wrapper.findAll('li')
// 断言前先判断存在
expect(wrapper.find('.error').exists()).toBe(true)
4.1 data-test 属性 vs CSS class
<button class="btn btn-primary" data-test="submit-button">提交</button>
// ❌ 容易随 CSS 改动失效
wrapper.find('.btn.btn-primary')
// ✅ data-test 是专门给测试用的
wrapper.find('[data-test="submit-button"]')
推荐:UI 样式 class 可能频繁改,加 data-test 或 data-testid 给测试用,不受样式变动影响。生产构建可用插件自动移除,不会污染 DOM。
5. 模拟用户交互
// 点击
await button.trigger('click')
// 键盘
await input.trigger('keydown', { key: 'Enter' })
await input.trigger('keydown.enter') // Vue Test Utils 的快捷写法
// 鼠标
await wrapper.trigger('mouseenter')
await wrapper.trigger('mouseleave')
// 提交表单
await form.trigger('submit.prevent')
// 输入
await input.setValue('hello') // input / textarea / select
await checkbox.setChecked(true) // 复选框
await radio.setChecked() // 单选
// 选择
await select.setValue('option2')
setValue vs trigger('input'):setValue 是推荐方式,它内部会触发正确的事件序列。
6. 测 props 与响应式
it('prop 变化触发重渲染', async () => {
const wrapper = mount(Counter, { props: { initial: 0 } })
await wrapper.setProps({ initial: 100 })
// 注意: setProps 不会重新执行 setup,count 不会变
// 除非组件里 watch 了 props
})
坑点:initial 在 <script setup> 里是 props.initial ?? 0 直接赋值给 ref——setProps 改 prop 后 count 不会变。这种场景要么:
- 在组件里
watch(() => props.initial, ...) - 或把
initial当"初始值"理解,测试时重新挂载
7. 测 slot
<!-- Dialog.vue -->
<template>
<div class="dialog">
<slot name="header">默认标题</slot>
<slot />
<slot name="footer" />
</div>
</template>
it('默认 header', () => {
const wrapper = mount(Dialog)
expect(wrapper.text()).toContain('默认标题')
})
it('具名 slot', () => {
const wrapper = mount(Dialog, {
slots: {
header: '自定义标题',
default: '<p>内容</p>',
footer: '<button>确定</button>',
},
})
expect(wrapper.text()).toContain('自定义标题')
expect(wrapper.find('button').text()).toBe('确定')
})
8. 测异步组件 / 接口调用
组件里发请求的场景:
<!-- NoteList.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { noteApi } from '@/api/note'
const notes = ref<any[]>([])
const loading = ref(true)
onMounted(async () => {
const res = await noteApi.page({ page: 1, pageSize: 10 })
notes.value = res.list
loading.value = false
})
</script>
<template>
<div v-if="loading">加载中...</div>
<ul v-else>
<li v-for="n in notes" :key="n.id">{{ n.title }}</li>
</ul>
</template>
import { describe, it, expect, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import NoteList from './NoteList.vue'
vi.mock('@/api/note', () => ({
noteApi: {
page: vi.fn().mockResolvedValue({
list: [
{ id: 1, title: '笔记 A' },
{ id: 2, title: '笔记 B' },
],
total: 2,
}),
},
}))
describe('NoteList', () => {
it('挂载后加载数据', async () => {
const wrapper = mount(NoteList)
expect(wrapper.text()).toContain('加载中')
await flushPromises() // 等所有 Promise 完成
expect(wrapper.findAll('li')).toHaveLength(2)
expect(wrapper.text()).toContain('笔记 A')
})
})
flushPromises() —— 让微任务队列清空,触发所有待处理的 Promise then。这是异步组件测试的关键工具。
9. 测带 Router / Pinia 的组件
9.1 Router
import { createRouter, createMemoryHistory } from 'vue-router'
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>Home</div>' } },
{ path: '/notes/:id', component: { template: '<div>Note</div>' } },
],
})
const wrapper = mount(MyComponent, {
global: {
plugins: [router],
},
})
await router.push('/notes/1')
await router.isReady()
createMemoryHistory 适合测试环境(不操作浏览器 URL)。
9.2 Pinia
import { createTestingPinia } from '@pinia/testing'
import { vi } from 'vitest'
const wrapper = mount(MyComponent, {
global: {
plugins: [createTestingPinia({
createSpy: vi.fn, // 让 actions 自动变 spy
initialState: {
user: { token: 'test-token', info: { username: 'alice', roles: ['admin'] } },
},
})],
},
})
createTestingPinia —— 专门为测试设计的 Pinia,自动 spy actions,可以直接预设 state。
pnpm add -D @pinia/testing
10. 测 Element Plus 组件
Element Plus 组件通常需要全局注册或单独 stub:
10.1 方案 A:全局注册
// src/__tests__/setup.ts
import ElementPlus from 'element-plus'
import { config } from '@vue/test-utils'
config.global.plugins = [ElementPlus]
// vitest.config.ts
export default defineConfig({
test: {
setupFiles: ['./src/__tests__/setup.ts'],
},
})
优点:测试里直接用 <el-button> 渲染真实组件。
缺点:测试速度慢一点。
10.2 方案 B:Stub 掉
const wrapper = mount(MyForm, {
global: {
stubs: {
'el-button': true, // 简单 stub
'el-input': {
template: '<input :value="modelValue" @input="$emit(`update:modelValue`, $event.target.value)" />',
props: ['modelValue'],
emits: ['update:modelValue'],
},
},
},
})
优点:测试快,关注本组件逻辑。 缺点:和真实交互有差距,要自己写 stub 模板。
推荐 A:in4vue 是小项目,全局注册 Element Plus 省心。大项目才考虑 stub 提速。
11. 调试组件测试:看不到页面怎么办
控制台加:
console.log(wrapper.html())
打出当前渲染的 HTML。比干猜快 10 倍。
更好的是Vitest UI 模式:
pnpm test:ui
浏览器里可视化看测试,每个组件的 HTML、props、emitted 都一目了然。
12. 常见交互模式
12.1 测表单提交
it('提交表单发请求', async () => {
const mockSubmit = vi.fn().mockResolvedValue({ id: 1 })
vi.mock('@/api/note', () => ({ noteApi: { create: mockSubmit } }))
const wrapper = mount(NoteForm)
await wrapper.find('[data-test="title-input"]').setValue('我的标题')
await wrapper.find('[data-test="submit"]').trigger('click')
await flushPromises()
expect(mockSubmit).toHaveBeenCalledWith(
expect.objectContaining({ title: '我的标题' })
)
})
expect.objectContaining —— 部分匹配,不用把所有字段都写全。
12.2 测条件渲染
it('未登录显示登录按钮', () => {
const wrapper = mount(Header, {
global: {
plugins: [createTestingPinia({
initialState: { user: { token: '' } },
})],
},
})
expect(wrapper.find('[data-test="login-btn"]').exists()).toBe(true)
expect(wrapper.find('[data-test="logout-btn"]').exists()).toBe(false)
})
it('已登录显示登出按钮', () => {
const wrapper = mount(Header, {
global: {
plugins: [createTestingPinia({
initialState: { user: { token: 'abc', info: { username: 'alice' } } },
})],
},
})
expect(wrapper.find('[data-test="login-btn"]').exists()).toBe(false)
expect(wrapper.find('[data-test="logout-btn"]').exists()).toBe(true)
})
12.3 测 v-model
it('双向绑定', async () => {
const wrapper = mount(MyInput, {
props: { modelValue: '初始值', 'onUpdate:modelValue': (v: string) => wrapper.setProps({ modelValue: v }) },
})
await wrapper.find('input').setValue('新值')
expect(wrapper.props('modelValue')).toBe('新值')
})
13. 反模式
13.1 测试实现细节
// ❌ 坏:测内部变量名和实现细节
expect((wrapper.vm as any).count).toBe(5)
// ✅ 好:测用户能看到的结果
expect(wrapper.find('.count').text()).toBe('5')
组件怎么实现用户不关心,他们关心屏幕上显示什么、点击后发生什么。测试也应该站在用户视角。
13.2 超长的单个测试
一个 it 里 30 行断言,搞乱了到底在测什么。一个测试测一件事:
// ❌ 一个 it 测太多
it('works', async () => {
// 测初始状态
expect(...)
// 点击加号
await ...
expect(...)
// 点击减号
await ...
expect(...)
// 重置
await ...
expect(...)
})
// ✅ 拆开
it('initial count is 0', () => { /* ... */ })
it('increment increases count', async () => { /* ... */ })
it('decrement decreases count', async () => { /* ... */ })
it('reset restores initial', async () => { /* ... */ })
失败时能立刻看到"哪件事挂了"。
13.3 大量 vi.mock 堆满文件
一个测试里 mock 10 个模块,读起来像阅读理解。降级:
- 抽
src/__tests__/fixtures/放假数据 - 抽
src/__tests__/mocks/放 mock 工厂函数
// src/__tests__/mocks/api.ts
export function mockNoteApi(overrides = {}) {
return {
page: vi.fn().mockResolvedValue({ list: [], total: 0 }),
...overrides,
}
}
14. 测试粒度建议
| 组件类型 | 要不要测 | 测什么 |
|---|---|---|
| 原子 UI(Button、Tag) | ✅ 测 | 渲染、click 事件 |
| 业务组件(NoteCard) | ✅ 测 | props → 渲染、关键交互 |
| 容器页面(NoteList) | 🟡 视情况 | 数据加载、核心流程 |
| 全站布局(AdminLayout) | ❌ 通常不测 | 测 e2e 更划算 |
| 纯展示(静态文字) | ❌ 不测 | 没逻辑 |
经验法则:有 emit 的组件一定要测 emit,有 props 的组件测 prop → 渲染/行为差异。
15. 常见坑点
| 现象 | 原因 | 解法 |
|---|---|---|
wrapper.find 找不到元素 |
可能在异步渲染之后才出现 | 先 await flushPromises() |
trigger 后断言失败 |
Vue DOM 更新异步 | 必须 await trigger(...) |
| Element Plus 组件报错 | 没注册 | setup.ts 里 config.global.plugins = [ElementPlus] |
| Router 相关报错 | 没 mock router | 用 createMemoryHistory |
(wrapper.vm).xxx 报类型错 |
wrapper.vm 类型弱 |
加 as any 或重构成测行为 |
| 测试互相干扰 | 共享状态没清 | beforeEach 里 setActivePinia(createPinia()) |
16. 心智模型:Arrange-Act-Assert
每个测试三段:
it('...', async () => {
// Arrange: 准备
const wrapper = mount(MyComponent, { props: { ... } })
// Act: 行动
await wrapper.find('button').trigger('click')
// Assert: 断言
expect(wrapper.emitted('submit')).toBeTruthy()
})
Java 对照:Mockito 社区叫 "Given-When-Then" ——一回事。
小练习
- 给
NoteCard组件(props: note)写测试:标题显示、点击抛 click 事件 - 给登录表单写测试:空值提交报错、正确填写后调 api
- 用
createTestingPinia测"已登录/未登录"两种状态的顶栏 - 给带接口调用的组件写测试,mock
noteApi.page返回假数据 - 尝试
pnpm test:ui,体验可视化测试运行
延伸阅读
- Vue Test Utils 官方文档
- Pinia Testing
- @pinia/testing
- Testing Library 的理念(更极端"用户视角"的测试哲学)
- Cypress Component Testing(另一种组件测试方案,真浏览器运行)