测试不是目的,可维护、可信心重构才是。坚持写测试,减少的不是代码量,是深夜被叫醒的概率。
前言:为什么选择 Vitest?
Vitest 是 Vite 原生的单元测试框架,核心优势:
- 零配置:与 Vite 共享配置,开箱即用
- 极速:基于 Vite 的 ESM 支持,启动速度比 Jest 快 10-100 倍
- 兼容 Jest:API 设计与 Jest 高度兼容,迁移成本低
- 原生 ESM:不需要 babel 转译,直接运行 TypeScript
- 内置功能丰富:覆盖率、Mock、Snapshot、UI 界面一应俱全
适用场景:Vue3 + Vite 项目。如果你用的是 Webpack + Jest,也可以迁移到 Vitest。
一、环境搭建与基础配置
1.1 安装依赖
npm install -D vitest @vue/test-utils @vitest/ui jsdom @vitest/coverage-v8 happy-dom
或者用 pnpm:
pnpm add -D vitest @vue/test-utils @vitest/ui jsdom @vitest/coverage-v8 happy-dom
依赖说明:
| 依赖 | 作用 |
|---|---|
| vitest | 测试框架核心 |
| @vue/test-utils | Vue 组件测试工具 |
| @vitest/ui | 可视化测试界面 |
| jsdom / happy-dom | 浏览器环境模拟(二选一) |
| @vitest/coverage-v8 | 覆盖率工具 |
jsdom vs happy-dom:
- jsdom:成熟稳定,但性能较慢
- happy-dom:性能更好,API 更现代,但某些边缘场景可能不支持
我的建议:优先用 happy-dom,遇到兼容问题时再切 jsdom。
1.2 Vitest 配置文件
创建 vitest.config.ts:
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
test: {
// 测试环境
environment: 'jsdom', // 或 'happy-dom'
// 全局 API(不需要 import describe/it/expect)
globals: true,
// 设置文件(全局测试配置)
setupFiles: ['./src/test/setup.ts'],
// 测试文件匹配模式
include: ['src/**/*.{test,spec}.{ts,tsx}'],
// 排除文件
exclude: ['node_modules', 'dist', '.idea', '.git', '.cache'],
// 覆盖率配置
coverage: {
provider: 'v8', // 或 'istanbul'
reporter: ['text', 'html', 'lcov'],
reportsDirectory: './coverage',
exclude: [
'node_modules/**',
'src/test/**',
'**/*.d.ts',
'**/*.config.*',
'**/index.ts', // 入口文件不计入
],
thresholds: {
statements: 80,
branches: 80,
functions: 80,
lines: 80,
},
},
// 并行执行
pool: 'threads', // 或 'forks'
poolOptions: {
threads: {
singleThread: false,
},
},
// 超时时间
testTimeout: 10000,
hookTimeout: 10000,
},
// 路径别名(与 Vite 配置保持一致)
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
})
1.3 全局测试设置文件
创建 src/test/setup.ts:
import { vi } from 'vitest'
import '@testing-library/jest-dom/vitest'
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
// Mock IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
disconnect() {}
observe() {}
takeRecords() {
return []
}
unobserve() {}
} as any
// Mock ResizeObserver
global.ResizeObserver = class ResizeObserver {
constructor() {}
disconnect() {}
observe() {}
unobserve() {}
} as any
1.4 package.json 脚本配置
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest watch"
}
}
1.5 VSCode 配置
创建 .vscode/settings.json:
{
"vitest.enable": true,
"vitest.rootPath": "${workspaceFolder}",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
}
创建 .vscode/extensions.json:
{
"recommendations": [
"vitest.explorer",
"dbaeumer.vscode-eslint"
]
}
二、组件测试:从简单到复杂
2.1 最简单的组件测试
<!-- components/HelloWorld.vue -->
<template>
<div class="hello">
<h1>{{ msg }}</h1>
<button @click="increment">Count: {{ count }}</button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{
msg: string
}>()
const count = ref(0)
function increment() {
count.value++
}
</script>
// components/HelloWorld.spec.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import HelloWorld from './HelloWorld.vue'
describe('HelloWorld', () => {
it('renders message', () => {
const msg = 'Hello Vitest'
const wrapper = mount(HelloWorld, {
props: { msg },
})
expect(wrapper.find('h1').text()).toBe(msg)
})
it('increments count when button clicked', async () => {
const wrapper = mount(HelloWorld, {
props: { msg: 'Test' },
})
expect(wrapper.find('button').text()).toBe('Count: 0')
await wrapper.find('button').trigger('click')
expect(wrapper.find('button').text()).toBe('Count: 1')
})
})
2.2 Props 测试
<!-- components/UserCard.vue -->
<template>
<div class="user-card">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
<span v-if="user.isAdmin" class="badge">管理员</span>
</div>
</template>
<script setup lang="ts">
interface User {
name: string
email: string
isAdmin: boolean
}
defineProps<{
user: User
}>()
</script>
// components/UserCard.spec.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import UserCard from './UserCard.vue'
describe('UserCard', () => {
it('renders user information', () => {
const user = {
name: '张三',
email: 'zhang@example.com',
isAdmin: false,
}
const wrapper = mount(UserCard, {
props: { user },
})
expect(wrapper.find('h3').text()).toBe('张三')
expect(wrapper.find('p').text()).toBe('zhang@example.com')
})
it('shows admin badge for admin users', () => {
const adminUser = {
name: '管理员',
email: 'admin@example.com',
isAdmin: true,
}
const wrapper = mount(UserCard, {
props: { user: adminUser },
})
expect(wrapper.find('.badge').exists()).toBe(true)
expect(wrapper.find('.badge').text()).toBe('管理员')
})
it('does not show badge for regular users', () => {
const regularUser = {
name: '普通用户',
email: 'user@example.com',
isAdmin: false,
}
const wrapper = mount(UserCard, {
props: { user: regularUser },
})
expect(wrapper.find('.badge').exists()).toBe(false)
})
})
2.3 Emits 测试
<!-- components/Counter.vue -->
<template>
<div>
<button @click="increment">+</button>
<span>{{ count }}</span>
<button @click="decrement">-</button>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
count: number
}>()
const emit = defineEmits<{
increment: []
decrement: []
change: [value: number]
}>()
function increment() {
emit('increment')
emit('change', props.count + 1)
}
function decrement() {
emit('decrement')
emit('change', props.count - 1)
}
</script>
// components/Counter.spec.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
describe('Counter', () => {
it('emits increment event', async () => {
const wrapper = mount(Counter, {
props: { count: 0 },
})
await wrapper.find('button:first-child').trigger('click')
expect(wrapper.emitted('increment')).toBeTruthy()
expect(wrapper.emitted('increment')).toHaveLength(1)
})
it('emits change event with new value', async () => {
const wrapper = mount(Counter, {
props: { count: 5 },
})
await wrapper.find('button:first-child').trigger('click')
expect(wrapper.emitted('change')).toBeTruthy()
expect(wrapper.emitted('change')?.[0]).toEqual([6])
})
it('emits both increment and change on click', async () => {
const wrapper = mount(Counter, {
props: { count: 0 },
})
await wrapper.find('button:first-child').trigger('click')
expect(wrapper.emitted('increment')).toHaveLength(1)
expect(wrapper.emitted('change')).toHaveLength(1)
})
})
2.4 Slots 测试
<!-- components/Card.vue -->
<template>
<div class="card">
<div class="card-header">
<slot name="header">
<h3>默认标题</h3>
</slot>
</div>
<div class="card-body">
<slot>
<p>默认内容</p>
</slot>
</div>
<div class="card-footer">
<slot name="footer" />
</div>
</div>
</template>
// components/Card.spec.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Card from './Card.vue'
describe('Card', () => {
it('renders default slots', () => {
const wrapper = mount(Card)
expect(wrapper.find('.card-header h3').text()).toBe('默认标题')
expect(wrapper.find('.card-body p').text()).toBe('默认内容')
})
it('renders custom header slot', () => {
const wrapper = mount(Card, {
slots: {
header: '<h2>自定义标题</h2>',
},
})
expect(wrapper.find('.card-header h2').text()).toBe('自定义标题')
})
it('renders custom default slot', () => {
const wrapper = mount(Card, {
slots: {
default: '<p>这是自定义内容</p>',
},
})
expect(wrapper.find('.card-body p').text()).toBe('这是自定义内容')
})
it('renders multiple slots', () => {
const wrapper = mount(Card, {
slots: {
header: '<h2>标题</h2>',
default: '<p>内容</p>',
footer: '<button>按钮</button>',
},
})
expect(wrapper.find('.card-header h2').exists()).toBe(true)
expect(wrapper.find('.card-body p').exists()).toBe(true)
expect(wrapper.find('.card-footer button').exists()).toBe(true)
})
})
2.5 v-model 测试
<!-- components/InputField.vue -->
<template>
<div class="input-field">
<label>{{ label }}</label>
<input
:value="modelValue"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
:placeholder="placeholder"
/>
</div>
</template>
<script setup lang="ts">
defineProps<{
label: string
modelValue: string
placeholder?: string
}>()
defineEmits<{
'update:modelValue': [value: string]
}>()
</script>
// components/InputField.spec.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import InputField from './InputField.vue'
describe('InputField', () => {
it('renders label and input', () => {
const wrapper = mount(InputField, {
props: {
label: '用户名',
modelValue: '',
},
})
expect(wrapper.find('label').text()).toBe('用户名')
expect(wrapper.find('input').exists()).toBe(true)
})
it('binds modelValue to input', () => {
const wrapper = mount(InputField, {
props: {
label: '用户名',
modelValue: 'test',
},
})
expect(wrapper.find('input').element.value).toBe('test')
})
it('emits update:modelValue on input', async () => {
const wrapper = mount(InputField, {
props: {
label: '用户名',
modelValue: '',
},
})
const input = wrapper.find('input')
await input.setValue('new value')
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['new value'])
})
it('renders placeholder', () => {
const wrapper = mount(InputField, {
props: {
label: '用户名',
modelValue: '',
placeholder: '请输入用户名',
},
})
expect(wrapper.find('input').attributes('placeholder')).toBe('请输入用户名')
})
})
2.6 异步组件测试
<!-- components/AsyncData.vue -->
<template>
<div>
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else class="data">{{ data }}</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const loading = ref(true)
const error = ref<string | null>(null)
const data = ref<string | null>(null)
onMounted(async () => {
try {
// 模拟异步数据获取
await new Promise((resolve) => setTimeout(resolve, 100))
data.value = '异步数据'
} catch (e) {
error.value = '加载失败'
} finally {
loading.value = false
}
})
</script>
// components/AsyncData.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import AsyncData from './AsyncData.vue'
describe('AsyncData', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('shows loading state initially', () => {
const wrapper = mount(AsyncData)
expect(wrapper.find('.loading').exists()).toBe(true)
})
it('shows data after loading', async () => {
const wrapper = mount(AsyncData)
// 快进时间
vi.advanceTimersByTime(100)
await wrapper.vm.$nextTick()
expect(wrapper.find('.loading').exists()).toBe(false)
expect(wrapper.find('.data').exists()).toBe(true)
expect(wrapper.find('.data').text()).toBe('异步数据')
})
it('handles error state', async () => {
// Mock setTimeout to reject
vi.spyOn(global, 'setTimeout').mockImplementation((fn) => {
fn()
return 0 as any
})
// 这里需要修改组件逻辑来测试错误状态
// 实际项目中通常通过注入依赖来 mock
})
})
三、Composables 测试
3.1 简单的 Composable 测试
// composables/useCounter.ts
import { ref, computed } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const doubled = computed(() => count.value * 2)
function increment() {
count.value++
}
function decrement() {
count.value--
}
function reset() {
count.value = initialValue
}
return {
count,
doubled,
increment,
decrement,
reset,
}
}
// composables/useCounter.spec.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('initializes with default value', () => {
const { count } = useCounter()
expect(count.value).toBe(0)
})
it('initializes with custom value', () => {
const { count } = useCounter(5)
expect(count.value).toBe(5)
})
it('increments count', () => {
const { count, increment } = useCounter()
increment()
expect(count.value).toBe(1)
})
it('decrements count', () => {
const { count, decrement } = useCounter(5)
decrement()
expect(count.value).toBe(4)
})
it('calculates doubled value', () => {
const { count, doubled } = useCounter(3)
expect(doubled.value).toBe(6)
count.value = 5
expect(doubled.value).toBe(10)
})
it('resets to initial value', () => {
const { count, increment, reset } = useCounter(0)
increment()
increment()
expect(count.value).toBe(2)
reset()
expect(count.value).toBe(0)
})
})
3.2 带 Watch 的 Composable 测试
// composables/useLocalStorage.ts
import { ref, watch } from 'vue'
export function useLocalStorage<T>(key: string, defaultValue: T) {
const value = ref<T>(
() => {
const stored = localStorage.getItem(key)
return stored ? JSON.parse(stored) : defaultValue
}
)
watch(
value,
(newValue) => {
if (newValue === null) {
localStorage.removeItem(key)
} else {
localStorage.setItem(key, JSON.stringify(newValue))
}
},
{ deep: true }
)
function reset() {
value.value = defaultValue
localStorage.removeItem(key)
}
return { value, reset }
}
// composables/useLocalStorage.spec.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { useLocalStorage } from './useLocalStorage'
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value
}),
removeItem: vi.fn((key: string) => {
delete store[key]
}),
clear: () => {
store = {}
},
}
})()
Object.defineProperty(global, 'localStorage', {
value: localStorageMock,
})
describe('useLocalStorage', () => {
beforeEach(() => {
localStorageMock.clear()
vi.clearAllMocks()
})
it('initializes with default value when not in storage', () => {
const { value } = useLocalStorage('test-key', 'default')
expect(value.value).toBe('default')
})
it('reads existing value from storage', () => {
localStorageMock.getItem.mockReturnValueOnce('"stored"')
const { value } = useLocalStorage('test-key', 'default')
expect(value.value).toBe('stored')
})
it('saves value to storage on change', async () => {
const { value } = useLocalStorage('test-key', 'default')
// 触发 watch
value.value = 'updated'
await vi.waitFor(() => {})
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'test-key',
'"updated"'
)
})
it('removes item from storage when set to null', async () => {
const { value } = useLocalStorage('test-key', 'default')
value.value = null as any
await vi.waitFor(() => {})
expect(localStorageMock.removeItem).toHaveBeenCalledWith('test-key')
})
it('resets to default and clears storage', async () => {
localStorageMock.getItem.mockReturnValueOnce('"old"')
const { value, reset } = useLocalStorage('test-key', 'default')
expect(value.value).toBe('old')
reset()
expect(value.value).toBe('default')
expect(localStorageMock.removeItem).toHaveBeenCalledWith('test-key')
})
})
四、Pinia Store 测试
4.1 Store 定义(Setup Store vs Options Store)
// stores/cart.ts - Setup Store
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
interface CartItem {
id: string
name: string
price: number
quantity: number
}
export const useCartStore = defineStore('cart', () => {
// State
const items = ref<CartItem[]>([])
const couponCode = ref<string | null>(null)
// Getters
const totalItems = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
)
const totalPrice = computed(() => {
const subtotal = items.value.reduce(
(sum, item) => sum + item.price * item.quantity,
0
)
// 简单折扣逻辑
if (couponCode.value === 'SAVE10') {
return subtotal * 0.9
}
return subtotal
})
// Actions
function addItem(item: Omit<CartItem, 'id'>) {
const existing = items.value.find((i) => i.name === item.name)
if (existing) {
existing.quantity += item.quantity
} else {
items.value.push({
...item,
id: crypto.randomUUID(),
})
}
}
function updateQuantity(itemId: string, quantity: number) {
const item = items.value.find((i) => i.id === itemId)
if (item) {
item.quantity = Math.max(0, quantity)
if (item.quantity === 0) {
removeItem(itemId)
}
}
}
function removeItem(itemId: string) {
const index = items.value.findIndex((i) => i.id === itemId)
if (index > -1) {
items.value.splice(index, 1)
}
}
function applyCoupon(code: string) {
couponCode.value = code
}
function clearCart() {
items.value = []
couponCode.value = null
}
return {
items,
couponCode,
totalItems,
totalPrice,
addItem,
updateQuantity,
removeItem,
applyCoupon,
clearCart,
}
})
4.2 独立 Store 测试(推荐)
// stores/cart.spec.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useCartStore } from './cart'
describe('useCartStore', () => {
beforeEach(() => {
// 创建隔离的 Pinia 实例
setActivePinia(createPinia())
})
describe('State & Getters', () => {
it('initializes with empty cart', () => {
const cart = useCartStore()
expect(cart.items).toEqual([])
expect(cart.totalItems).toBe(0)
expect(cart.totalPrice).toBe(0)
})
it('calculates total items correctly', () => {
const cart = useCartStore()
cart.items = [
{ id: '1', name: '苹果', price: 5, quantity: 2 },
{ id: '2', name: '香蕉', price: 3, quantity: 3 },
]
expect(cart.totalItems).toBe(5)
})
it('calculates total price correctly', () => {
const cart = useCartStore()
cart.items = [
{ id: '1', name: '苹果', price: 5, quantity: 2 },
]
expect(cart.totalPrice).toBe(10)
})
it('applies coupon discount', () => {
const cart = useCartStore()
cart.items = [{ id: '1', name: '苹果', price: 100, quantity: 1 }]
cart.applyCoupon('SAVE10')
expect(cart.totalPrice).toBe(90)
})
})
describe('Actions', () => {
it('adds new item', () => {
const cart = useCartStore()
cart.addItem({ name: '橙子', price: 8, quantity: 1 })
expect(cart.items).toHaveLength(1)
expect(cart.items[0].name).toBe('橙子')
expect(cart.items[0].price).toBe(8)
})
it('increases quantity for existing item', () => {
const cart = useCartStore()
cart.addItem({ name: '苹果', price: 5, quantity: 1 })
cart.addItem({ name: '苹果', price: 5, quantity: 2 })
expect(cart.items).toHaveLength(1)
expect(cart.items[0].quantity).toBe(3)
})
it('updates item quantity', () => {
const cart = useCartStore()
cart.addItem({ name: '香蕉', price: 3, quantity: 1 })
const itemId = cart.items[0].id
cart.updateQuantity(itemId, 5)
expect(cart.items[0].quantity).toBe(5)
})
it('removes item when quantity set to 0', () => {
const cart = useCartStore()
cart.addItem({ name: '葡萄', price: 12, quantity: 2 })
const itemId = cart.items[0].id
cart.updateQuantity(itemId, 0)
expect(cart.items).toHaveLength(0)
})
it('removes item directly', () => {
const cart = useCartStore()
cart.addItem({ name: '草莓', price: 15, quantity: 1 })
const itemId = cart.items[0].id
cart.removeItem(itemId)
expect(cart.items).toHaveLength(0)
})
it('clears entire cart', () => {
const cart = useCartStore()
cart.addItem({ name: '苹果', price: 5, quantity: 1 })
cart.addItem({ name: '香蕉', price: 3, quantity: 2 })
cart.applyCoupon('SAVE10')
cart.clearCart()
expect(cart.items).toEqual([])
expect(cart.couponCode).toBeNull()
expect(cart.totalPrice).toBe(0)
})
})
})
五、覆盖率配置与 CI 集成
5.1 覆盖率配置详解
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8', // 或 'istanbul'
reporter: ['text', 'text-summary', 'html', 'lcov', 'json'],
reportsDirectory: './coverage',
// 文件排除
exclude: [
'node_modules/**',
'src/test/**',
'**/*.d.ts',
'**/*.config.*',
'**/index.ts', // 入口文件不计入
],
// 包含特定文件
include: ['src/**/*.ts', 'src/**/*.vue'],
// 阈值配置(低于会失败)
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
// 每文件阈值
perFile: true,
100: ['**/*.util.ts'], // 工具函数要求 100%
// 忽略未覆盖的文件
allowEmptyLines: true,
ignoreClassMethods: ['render', 'setup'],
},
},
},
})
5.2 GitHub Actions 配置
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run unit tests
run: pnpm test:run
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
if: success()
with:
files: ./coverage/lcov.info
flags: unittests
name: vitest-coverage
- name: Upload coverage to GitHub
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: coverage/
六、常见踩坑与 FAQ
6.1 异步测试相关
❌ 错误:直接检查未 await 的异步状态
// 错误写法
it('fetches data', () => {
const wrapper = mount(AsyncComponent)
// 此时 Promise 还未 resolve
expect(wrapper.find('.loading').exists()).toBe(false) // 测试通过是假象
})
✅ 正确写法:使用 vi.waitFor 或 await nextTick
// 方法 1: vi.waitFor(推荐)
it('fetches data', async () => {
const wrapper = mount(AsyncComponent)
await vi.waitFor(() => {
expect(wrapper.find('.loading').exists()).toBe(false)
})
})
6.2 组件挂载相关
❌ 错误:忘记配置 Vue Router/Pinia
// 组件依赖 Pinia store,但不提供
const wrapper = mount(ComponentUsingStore) // 报错:getActivePinia() was called with no active Pinia instance
✅ 正确:提供必要的全局配置
const wrapper = mount(ComponentUsingStore, {
global: {
plugins: [createTestingPinia()],
},
})
6.3 DOM 更新等待
❌ 错误:trigger 后立即断言
await wrapper.find('button').trigger('click')
expect(wrapper.find('.count').text()).toBe('1') // 可能还是 '0'
✅ 正确:await nextTick
await wrapper.find('button').trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.find('.count').text()).toBe('1')
6.4 Pinia Store 测试陷阱
❌ 错误:在不同测试间共享 store 状态
const store = useCartStore() // 全局 store 实例,状态会累积
✅ 正确:在 beforeEach 中创建新 Pinia
beforeEach(() => {
setActivePinia(createPinia()) // 每个测试都是全新状态
})
6.5 Mock 模块加载顺序
❌ 错误:vi.mock 在导入之后
import { myFunction } from './module' // 先导入
vi.mock('./module', () => ({...})) // ❌ 太晚了
✅ 正确:vi.mock 必须在导入之前
vi.mock('./module', () => ({
myFunction: vi.fn().mockReturnValue('mocked'),
}))
import { myFunction } from './module' // 后导入
总结
本文覆盖了 Vue3 + Vitest 单元测试的核心场景,核心要点:
- 配置:Vitest 与 Vite 零配置集成,核心是
environment: 'jsdom'+globals: true - 组件测试:mount vs shallowMount 选型,props/emits/slots/v-model 全覆盖
- Composables 测试:直接测试函数返回值和响应式状态
- Pinia 测试:Setup Store 更易测试,
createTestingPinia处理全局配置 - Mock 技巧:
vi.fn()追踪调用,vi.spyOn()监听方法,vi.mock()模块级替换 - 异步处理:
vi.waitFor、flushPromises、vi.useFakeTimers()三大神器 - CI 集成:GitHub Actions / GitLab CI 配置覆盖率上报
- 避坑:异步必须 await、mock 必须在 import 之前、timer mock 必须 restore
测试不是目的,可维护、可信心重构才是。坚持写测试,减少的不是代码量,是深夜被叫醒的概率。
参考资料