295 lines
5.9 KiB
Markdown
295 lines
5.9 KiB
Markdown
# 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/)
|