5.9 KiB
5.9 KiB
Vue 测试
Vue 3 组件、组合式函数和工具函数的测试模式。
快速参考
| 测试类型 | 模式 |
|---|---|
| 组件 | mount(Component, { props, slots }) |
| 用户交互 | await wrapper.trigger('click') |
| 触发的事件 | wrapper.emitted('update') |
| 组合式函数 | 直接调用,测试返回值 |
| 工具函数 | 纯函数测试(最简单) |
技术栈
- Vitest — 测试运行器
- @vue/test-utils — 组件挂载、交互
- @testing-library/vue — 以用户为中心的替代方案
- happy-dom / jsdom — DOM 环境
文件位置
将测试文件与代码放在一起:
Button.vue → Button.spec.ts
useAuth.ts → useAuth.spec.ts
formatters.ts → formatters.spec.ts
组件测试
基本用法
import { mount } from '@vue/test-utils'
import Button from './Button.vue'
it('渲染插槽', () => {
const wrapper = mount(Button, {
slots: { default: '点击我' }
})
expect(wrapper.text()).toBe('点击我')
})
it('点击时触发事件', async () => {
const wrapper = mount(Button)
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toHaveLength(1)
})
Props
it('应用 variant 类名', () => {
const wrapper = mount(Button, {
props: { variant: 'primary' }
})
expect(wrapper.classes()).toContain('btn-primary')
})
事件触发
it('触发 update 事件并携带载荷', async () => {
const wrapper = mount(Input)
await wrapper.find('input').setValue('新值')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['新值'])
})
插槽
it('渲染具名插槽', () => {
const wrapper = mount(Card, {
slots: {
header: '<h1>标题</h1>',
default: '<p>内容</p>'
}
})
expect(wrapper.html()).toContain('<h1>标题</h1>')
})
组合式函数测试
直接调用,无需挂载:
import { useCounter } from './useCounter'
it('递增计数', () => {
const { count, increment } = useCounter(0)
expect(count.value).toBe(0)
increment()
expect(count.value).toBe(1)
})
it('重置为初始值', () => {
const { count, increment, reset } = useCounter(5)
increment()
increment()
expect(count.value).toBe(7)
reset()
expect(count.value).toBe(5)
})
工具函数测试
最简单——纯函数:
import { formatCurrency, slugify } from './formatters'
describe('formatCurrency', () => {
it('格式化美元金额', () => {
expect(formatCurrency(10.5)).toBe('$10.50')
})
})
describe('slugify', () => {
it('转换为小写', () => {
expect(slugify('Hello World')).toBe('hello-world')
})
it('移除特殊字符', () => {
expect(slugify('Hello! World?')).toBe('hello-world')
})
})
Mock
组合式函数:
import { vi } from 'vitest'
vi.mock('./useAuth', () => ({
useAuth: vi.fn(() => ({
user: { id: 1, name: 'Test' },
isAuthenticated: true
}))
}))
API 调用:
global.fetch = vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ data: [] })
})
)
Router Mock
在组件测试中 Mock useRoute 和 useRouter:
import { vi } from 'vitest'
import { mount } from '@vue/test-utils'
vi.mock('vue-router', () => ({
useRoute: vi.fn(() => ({
params: { id: '123' },
query: { filter: 'active' },
path: '/users/123',
})),
useRouter: vi.fn(() => ({
push: vi.fn(),
replace: vi.fn(),
})),
}))
it('使用路由参数', () => {
const wrapper = mount(UserPage)
expect(wrapper.text()).toContain('123')
})
每个测试的动态路由 Mock:
import { useRoute } from 'vue-router'
it('处理不同的路由', () => {
vi.mocked(useRoute).mockReturnValue({
params: { id: '456' },
} as any)
const wrapper = mount(UserPage)
expect(wrapper.text()).toContain('456')
})
Suspense 与 Teleport
使用 Suspense 测试异步组件:
import { flushPromises, mount } from '@vue/test-utils'
it('渲染异步内容', async () => {
const wrapper = mount(AsyncComponent, {
global: {
stubs: { Suspense: false }, // 不对 Suspense 进行桩替换
},
})
// 等待异步 setup 完成
await flushPromises()
expect(wrapper.text()).toContain('已加载内容')
})
测试 Teleport:
it('传送模态框内容', () => {
const wrapper = mount(Modal, {
global: {
stubs: {
teleport: true, // 将 teleport 替换为桩,使其内联渲染
},
},
})
expect(wrapper.text()).toContain('模态框内容')
})
访问被传送的内容:
it('查找被传送的内容', () => {
document.body.innerHTML = '<div id="modal-target"></div>'
mount(Modal, { props: { open: true } })
// 内容被传送到 #modal-target
expect(document.body.innerHTML).toContain('模态框内容')
})
最佳实践
应做:
- 测试行为(用户看到/操作的内容),而非实现细节
- 采用 Arrange-Act-Assert(准备—执行—断言)结构
- 每个测试只做一个断言
- 使用描述性的测试名称
- Mock 外部依赖
不应做:
- 测试 Vue 内部机制(响应式系统)
- 测试第三方库
- 测试琐碎的 getter/setter
- 测试实现细节
测试内容
需要测试:
- 用户交互(点击、输入)
- 条件渲染
- Props 验证、触发的事件
- 计算属性、业务逻辑
跳过:
- Vue 内部机制、第三方库
- 琐碎的 getter/setter
- 实现细节
运行
pnpm test # 全部
pnpm exec vitest Button.spec.ts # 指定文件
pnpm exec vitest --watch # 监听模式
pnpm test --coverage # 覆盖率