Files
2026-07-13 21:37:07 +08:00

295 lines
5.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```
## 组件测试
### 基本用法
```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
```ts
it('应用 variant 类名', () => {
const wrapper = mount(Button, {
props: { variant: 'primary' }
})
expect(wrapper.classes()).toContain('btn-primary')
})
```
### 事件触发
```ts
it('触发 update 事件并携带载荷', async () => {
const wrapper = mount(Input)
await wrapper.find('input').setValue('新值')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['新值'])
})
```
### 插槽
```ts
it('渲染具名插槽', () => {
const wrapper = mount(Card, {
slots: {
header: '<h1>标题</h1>',
default: '<p>内容</p>'
}
})
expect(wrapper.html()).toContain('<h1>标题</h1>')
})
```
## 组合式函数测试
直接调用,无需挂载:
```ts
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)
})
```
## 工具函数测试
最简单——纯函数:
```ts
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
**组合式函数:**
```ts
import { vi } from 'vitest'
vi.mock('./useAuth', () => ({
useAuth: vi.fn(() => ({
user: { id: 1, name: 'Test' },
isAuthenticated: true
}))
}))
```
**API 调用:**
```ts
global.fetch = vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ data: [] })
})
)
```
## Router Mock
在组件测试中 Mock `useRoute``useRouter`
```ts
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**
```ts
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 测试异步组件:**
```ts
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**
```ts
it('传送模态框内容', () => {
const wrapper = mount(Modal, {
global: {
stubs: {
teleport: true, // 将 teleport 替换为桩,使其内联渲染
},
},
})
expect(wrapper.text()).toContain('模态框内容')
})
```
**访问被传送的内容:**
```ts
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
- 实现细节
## 运行
```bash
pnpm test # 全部
pnpm exec vitest Button.spec.ts # 指定文件
pnpm exec vitest --watch # 监听模式
pnpm test --coverage # 覆盖率
```
**文档:** [vitest.dev](https://vitest.dev/) · [test-utils.vuejs.org](https://test-utils.vuejs.org/)