Vue3 测试(Vitest)
大约 10 分钟约 2984 字
Vue3 测试(Vitest)
简介
Vitest 是基于 Vite 的新一代测试框架,与 Vue3 + Vite 项目无缝集成。它支持组件测试、组合式函数测试和端到端测试,速度快且配置简单。
Vitest 由 Vue/Vite 核心团队成员维护,与 Jest API 高度兼容,但速度更快(利用 Vite 的转换管道)。它支持以下测试类型:
- 单元测试:测试纯函数、工具函数、composable
- 组件测试:使用 Vue Test Utils 挂载组件,测试渲染和交互
- 端到端测试:使用 Playwright/Cypress 测试完整用户流程
Vitest vs Jest:
| 特性 | Vitest | Jest |
|---|---|---|
| 速度 | 快(共享 Vite 配置和转换管道) | 慢(独立的转换管道) |
| ESM 支持 | 原生支持 | 需要额外配置 |
| TypeScript | 原生支持(无需 ts-jest) | 需要 ts-jest |
| Vue SFC | 原生支持(@vitejs/plugin-vue) | 需要 vue-jest |
| 配置 | 共享 vite.config | 独立的 jest.config |
| API | 兼容 Jest | Jest 原生 |
| 热更新 | 支持(修改测试自动重跑) | 支持 |
特点
配置
安装和配置
# 安装
npm install -D vitest @vue/test-utils happy-dom// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'happy-dom',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.{ts,vue}']
},
globals: true
}
})// package.json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}测试环境选择
// vitest.config.ts —— 测试环境配置
export default defineConfig({
test: {
// 环境选择
// happy-dom: 轻量级 DOM 实现,速度快(推荐)
// jsdom: 完整 DOM 实现,兼容性好,速度较慢
// node: 纯 Node.js 环境(无 DOM),适合测试纯函数
environment: 'happy-dom',
// 全局 API(不需要每个文件都 import)
globals: true,
// 测试文件匹配模式
include: ['src/**/*.{test,spec}.{ts,tsx}'],
// 排除文件
exclude: ['node_modules', 'dist', '.idea', '.git', '.vscode'],
// 全局 setup 文件
setupFiles: ['./tests/setup.ts'],
// 覆盖率配置
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
include: ['src/**/*.{ts,vue}'],
exclude: [
'src/**/*.d.ts',
'src/**/*.test.ts',
'src/main.ts',
'src/router/index.ts',
],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
// 模拟时间
// fakeTimers: { now: new Date('2024-01-01') },
// 超时设置
testTimeout: 10000,
hookTimeout: 10000,
},
})// tests/setup.ts —— 全局 setup
import { config } from '@vue/test-utils'
// 全局配置 Vue Test Utils
config.global.stubs = {
// 全局 stub
}
// 全局 mock
globalThis.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
// 全局 mock 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(),
})),
})组合式函数测试
测试 Composable
// src/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 }
}// tests/composables/useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from '@/composables/useCounter'
describe('useCounter', () => {
it('初始值正确', () => {
const { count, doubled } = useCounter(5)
expect(count.value).toBe(5)
expect(doubled.value).toBe(10)
})
it('increment 增加计数', () => {
const { count, increment } = useCounter()
increment()
expect(count.value).toBe(1)
})
it('decrement 减少计数', () => {
const { count, decrement } = useCounter(3)
decrement()
expect(count.value).toBe(2)
})
it('reset 重置计数', () => {
const { count, reset } = useCounter(10)
count.value = 99
reset()
expect(count.value).toBe(10)
})
})测试异步 Composable
// src/composables/useAsyncData.ts
import { ref } from 'vue'
export function useAsyncData<T>(fetchFn: () => Promise<T>) {
const data = ref<T | null>(null) as Ref<T | null>
const error = ref<Error | null>(null)
const loading = ref(false)
async function execute() {
loading.value = true
error.value = null
try {
data.value = await fetchFn()
} catch (e) {
error.value = e instanceof Error ? e : new Error(String(e))
} finally {
loading.value = false
}
}
return { data, error, loading, execute }
}// tests/composables/useAsyncData.test.ts
import { describe, it, expect, vi } from 'vitest'
import { useAsyncData } from '@/composables/useAsyncData'
describe('useAsyncData', () => {
it('成功获取数据', async () => {
const mockData = { id: 1, name: '张三' }
const fetchFn = vi.fn().mockResolvedValue(mockData)
const { data, error, loading, execute } = useAsyncData(fetchFn)
expect(loading.value).toBe(false)
expect(data.value).toBeNull()
const promise = execute()
expect(loading.value).toBe(true)
await promise
expect(loading.value).toBe(false)
expect(data.value).toEqual(mockData)
expect(error.value).toBeNull()
expect(fetchFn).toHaveBeenCalledTimes(1)
})
it('处理错误', async () => {
const fetchFn = vi.fn().mockRejectedValue(new Error('网络错误'))
const { data, error, loading, execute } = useAsyncData(fetchFn)
await execute()
expect(error.value).toBeInstanceOf(Error)
expect(error.value?.message).toBe('网络错误')
expect(data.value).toBeNull()
})
})测试带 Timer 的 Composable
// tests/composables/useDebounce.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
describe('useDebounce', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('延迟更新值', () => {
const { value, update } = useDebounce('', 300)
update('hello')
expect(value.value).toBe('')
vi.advanceTimersByTime(300)
expect(value.value).toBe('hello')
})
it('防抖:快速多次更新只保留最后一次', () => {
const { value, update } = useDebounce('', 300)
update('a')
update('ab')
update('abc')
update('abcd')
vi.advanceTimersByTime(299)
expect(value.value).toBe('')
vi.advanceTimersByTime(1)
expect(value.value).toBe('abcd')
})
})组件测试
基本组件测试
<!-- src/components/Counter.vue -->
<template>
<div class="counter">
<h2>计数器: {{ count }}</h2>
<p>双倍: {{ doubled }}</p>
<button @click="increment">+1</button>
<button @click="decrement">-1</button>
<button @click="reset">重置</button>
</div>
</template>
<script setup lang="ts">
import { useCounter } from '@/composables/useCounter'
const { count, doubled, increment, decrement, reset } = useCounter(0)
</script>// tests/components/Counter.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from '@/components/Counter.vue'
describe('Counter.vue', () => {
it('渲染初始计数', () => {
const wrapper = mount(Counter)
expect(wrapper.find('h2').text()).toContain('计数器: 0')
})
it('点击按钮增加计数', async () => {
const wrapper = mount(Counter)
await wrapper.findAll('button')[0].trigger('click') // +1
expect(wrapper.find('h2').text()).toContain('计数器: 1')
})
it('点击重置按钮', async () => {
const wrapper = mount(Counter)
await wrapper.findAll('button')[0].trigger('click') // +1
await wrapper.findAll('button')[2].trigger('click') // 重置
expect(wrapper.find('h2').text()).toContain('计数器: 0')
})
})带Props和事件
// 测试 Props 和事件
describe('UserCard.vue', () => {
it('显示用户信息', () => {
const wrapper = mount(UserCard, {
props: {
user: { id: 1, name: '张三', email: 'zhang@example.com' }
}
})
expect(wrapper.text()).toContain('张三')
expect(wrapper.text()).toContain('zhang@example.com')
})
it('点击删除触发事件', async () => {
const wrapper = mount(UserCard, {
props: {
user: { id: 1, name: '张三', email: 'zhang@example.com' }
}
})
await wrapper.find('.delete-btn').trigger('click')
expect(wrapper.emitted('delete')).toBeTruthy()
expect(wrapper.emitted('delete')![0]).toEqual([{ id: 1 }])
})
})测试 Slot
describe('Card.vue with slots', () => {
it('渲染默认 slot', () => {
const wrapper = mount(Card, {
slots: {
default: '卡片内容'
}
})
expect(wrapper.text()).toContain('卡片内容')
})
it('渲染具名 slot', () => {
const wrapper = mount(Card, {
slots: {
header: '<h2>标题</h2>',
footer: '<div class="footer">底部</div>',
default: '<p>内容</p>',
}
})
expect(wrapper.find('h2').text()).toBe('标题')
expect(wrapper.find('.footer').text()).toBe('底部')
expect(wrapper.find('p').text()).toBe('内容')
})
it('slot 传递 props(作用域插槽)', () => {
const wrapper = mount(ItemList, {
slots: {
default: `<template #default="{ item, index }">{{ index }}: {{ item.name }}</template>`
},
props: {
items: [{ name: 'A' }, { name: 'B' }]
}
})
expect(wrapper.text()).toContain('0: A')
expect(wrapper.text()).toContain('1: B')
})
})测试 v-if/v-show 条件渲染
describe('条件渲染', () => {
it('v-if 显示/隐藏', async () => {
const wrapper = mount(UserProfile, {
props: { isLoggedIn: false }
})
expect(wrapper.find('.login-form').exists()).toBe(true)
expect(wrapper.find('.user-info').exists()).toBe(false)
await wrapper.setProps({ isLoggedIn: true })
expect(wrapper.find('.login-form').exists()).toBe(false)
expect(wrapper.find('.user-info').exists()).toBe(true)
})
it('v-show 切换', async () => {
const wrapper = mount(Tooltip, {
props: { visible: false }
})
expect(wrapper.find('.tooltip-content').isVisible()).toBe(false)
await wrapper.setProps({ visible: true })
expect(wrapper.find('.tooltip-content').isVisible()).toBe(true)
})
})Mock 和 Stub
API Mock
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import UserList from '@/components/UserList.vue'
// Mock API 模块
vi.mock('@/api/users', () => ({
fetchUsers: vi.fn()
}))
describe('UserList.vue', () => {
beforeEach(() => {
vi.resetAllMocks()
})
it('加载并显示用户列表', async () => {
const { fetchUsers } = await import('@/api/users')
fetchUsers.mockResolvedValue([
{ id: 1, name: '张三' },
{ id: 2, name: '李四' }
])
const wrapper = mount(UserList, {
global: {
plugins: [/* Pinia/Router 插件 */]
}
})
// 等待异步更新
await wrapper.vm.$nextTick()
// 可能需要 flushPromises()
// await new Promise(resolve => setTimeout(resolve, 0))
expect(wrapper.text()).toContain('张三')
expect(wrapper.text()).toContain('李四')
})
})Mock 的多种方式
// 1. vi.mock —— 模块级别 Mock(自动提升到文件顶部)
vi.mock('@/api/users', () => ({
fetchUsers: vi.fn().mockResolvedValue([{ id: 1, name: '张三' }]),
}))
// 2. vi.fn —— 创建间谍函数
const callback = vi.fn()
callback('hello')
expect(callback).toHaveBeenCalledWith('hello')
expect(callback).toHaveBeenCalledTimes(1)
// 3. vi.spyOn —— 监听已有方法
const obj = { method: () => 'original' }
const spy = vi.spyOn(obj, 'method')
obj.method()
expect(spy).toHaveBeenCalled()
spy.mockReturnValue('mocked')
expect(obj.method()).toBe('mocked')
spy.mockRestore()
// 4. vi.stubGlobal —— Mock 全局对象
vi.stubGlobal('localStorage', {
getItem: vi.fn().mockReturnValue(null),
setItem: vi.fn(),
removeItem: vi.fn(),
})Stub 组件
// Stub 子组件(避免渲染真实的子组件)
const wrapper = mount(ParentComponent, {
global: {
stubs: {
// Stub 所有子组件
ChildComponent: true,
// Stub 并自定义模板
UserCard: {
template: '<div class="stub-user-card">{{ user.name }}</div>',
props: ['user'],
},
},
},
})
// Stub 路由和 Pinia
import { createTestingPinia } from '@pinia/testing'
const wrapper = mount(MyComponent, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn, // 所有 actions 自动成为 spy
stubActions: false, // 不 stub actions(执行真实逻辑)
}),
router, // 使用真实路由
],
},
})断言 API 速查
// 常用断言
expect(value).toBe(expected) // 严格相等 (===)
expect(value).toEqual(expected) // 深度相等
expect(value).toBeTruthy() // 真值
expect(value).toBeFalsy() // 假值
expect(value).toBeNull() // null
expect(value).toBeUndefined() // undefined
expect(value).toBeDefined() // 非 undefined
expect(value).toBeGreaterThan(n) // 大于
expect(value).toBeLessThan(n) // 小于
expect(value).toContain(str) // 字符串/数组包含
expect(value).toHaveLength(n) // 长度
expect(value).toMatch(regex) // 正则匹配
expect(value).toThrow() // 抛出错误
expect(fn).toHaveBeenCalled() // 被调用
expect(fn).toHaveBeenCalledTimes(n) // 调用次数
expect(fn).toHaveBeenCalledWith(a, b) // 以特定参数调用
expect(fn).toHaveBeenLastCalledWith(a) // 最后一次调用的参数
// Vue Test Utils 断言
expect(wrapper.find('.btn').exists()).toBe(true)
expect(wrapper.findAll('li')).toHaveLength(3)
expect(wrapper.text()).toContain('hello')
expect(wrapper.html()).toMatch(/<button/)
expect(wrapper.emitted()).toHaveProperty('click')
expect(wrapper.emitted('update:modelValue')).toBeTruthy()优点
缺点
总结
Vitest 核心配置:vitest.config.ts 共享 Vite 插件,environment 设为 happy-dom。Composable 测试直接调用函数验证返回值。组件测试用 @vue/test-utils 的 mount 挂载、trigger 触发事件、emitted 验证事件。Mock 用 vi.mock 替换模块、vi.fn 创建间谍。运行 vitest --watch 开发模式,vitest run --coverage 查看覆盖率。
测试策略建议:
- 优先测试 composable(纯逻辑,无 DOM 依赖,测试简单)
- 组件测试重点测交互逻辑和边界条件,不测样式
- 使用 createTestingPinia 测试依赖 Store 的组件
- API 层用 vi.mock 模拟,不发起真实请求
- 覆盖率关注核心业务逻辑,不追求 100%
- CI 中运行
vitest run,开发中用vitest --watch
关键知识点
- 先判断主题更偏浏览器原理、框架机制、工程化还是性能优化。
- 前端问题很多看似是页面问题,实际源头在构建、缓存、状态流或接口协作。
- 真正成熟的前端方案一定同时考虑首屏、交互、可维护性和线上诊断。
- 前端主题最好同时看浏览器原理、框架机制和工程化约束。
项目落地视角
- 把组件边界、状态归属、网络层规范和错误处理先定下来。
- 上线前检查包体积、缓存命中、接口失败路径和关键交互降级策略。
- 如果主题和性能有关,最好用 DevTools、Lighthouse 或埋点验证。
- 对关键页面先建立状态流和数据流,再考虑组件拆分。
常见误区
- 只盯框架 API,不理解浏览器和运行时成本。
- 把状态、请求和 UI 更新混成一层,后期难维护。
- 线上问题出现时没有日志、埋点和性能基线可对照。
- 只追框架新特性,不分析实际渲染成本。
进阶路线
- 继续补齐 SSR、边缘渲染、设计系统和监控告警能力。
- 把主题和后端接口约定、CI/CD、缓存策略一起思考。
- 沉淀组件规范、页面模板和性能基线,减少团队差异。
- 继续补齐设计系统、SSR/边缘渲染、监控告警和组件库治理。
适用场景
- 当你准备把《Vue3 测试(Vitest)》真正落到项目里时,最适合先在一个独立模块或最小样例里验证关键路径。
- 适合中后台应用、门户站点、组件库和实时交互页面。
- 当需求涉及状态流、路由、网络缓存、SSR/CSR 或性能治理时,这类主题很关键。
落地建议
- 先定义组件边界和状态归属,再落地 UI 细节。
- 对核心页面做首屏、体积、缓存和错误路径检查。
- 把安全、兼容性和可访问性纳入默认交付标准。
排错清单
- 先用浏览器 DevTools 看请求、性能面板和控制台错误。
- 检查依赖版本、构建配置、环境变量和静态资源路径。
- 如果是线上问题,优先确认缓存、CDN 和构建产物是否一致。
复盘问题
- 如果把《Vue3 测试(Vitest)》放进你的当前项目,最先要验证的输入、输出和失败路径分别是什么?
- 《Vue3 测试(Vitest)》最容易在什么规模、什么边界条件下暴露问题?你会用什么指标或日志去确认?
- 相比默认实现或替代方案,采用《Vue3 测试(Vitest)》最大的收益和代价分别是什么?
