在 Vue 3 生态中,单元测试长期面临一个矛盾:开发环境使用 Vite 享受极快的热更新,而测试环境却被迫切换到基于 Node 的 Jest 或 Mocha,导致配置分裂、依赖重复、运行缓慢。Vitest 的出现改变了这一局面,它基于 Vite 原生能力,让测试与开发共享同一条构建管线。本文将深入解析 Vitest 在 Vue 3 工程中的实践方法。

一、Vitest 与 Vite 的协同机制
Vitest 并不是在 Vite 上层重新实现一套测试运行器,而是直接复用了 Vite 的插件容器、依赖预构建和模块解析逻辑。当你启动 Vitest 时,它会创建一个与 Vite 开发服务器相似的运行环境,测试文件中的 import 语句会经过 Vite 的转换管线处理。这意味着在 Vue 3 项目中,@vitejs/plugin-vue 会自动作用于测试文件中的 .vue 导入,你无需为测试环境单独配置 Vue 单文件组件的编译规则。
这种协同机制最大的好处是消除了测试配置与开发配置的漂移。过去使用 Jest 时,为了让 Jest 理解 .vue 文件,通常需要额外配置 vue-jest 或 babel 转换插件,而这些插件的版本与 Vite 插件可能存在差异,导致测试能通过但生产构建报错,或者相反。Vitest 从根源上规避了这类问题,因为测试执行时使用的就是 Vite 项目里已经存在的 vite.config.ts 中的插件配置。
另一个显著优势是启动速度。Vitest 的 watch 模式基于 Vite 的模块图,能够精确识别受影响的范围并只重新执行相关测试文件,而不是像传统测试框架那样重新构建整个测试套件。对于大型 Vue 3 工程,这种增量执行策略可以将单次测试反馈时间从几十秒压缩到几百毫秒。
二、Vue 3 项目中的最小化配置
将一个已有的 Vue 3 + Vite 项目接入 Vitest 非常简单。首先安装必要的开发依赖:
npm install -D vitest @vue/test-utils jsdom
接着在 vite.config.ts 中补充 test 字段。Vitest 会读取这个字段作为测试配置,同时继续沿用 Vite 的插件、resolve 别名和构建选项。下面是一个典型配置:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': '/src'
}
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
coverage: {
provider: 'v8',
reporter: ['text', 'html']
}
}
})
上述配置中,globals: true 让测试文件无需显式导入 describe、it、expect 等全局函数,提升编写效率。environment: 'jsdom' 为组件渲染提供浏览器 DOM 模拟环境,确保 mount 能够正常工作。如果测试的是纯逻辑 composable 或不涉及 DOM 的模块,也可以使用默认的 node 环境,但 Vue 组件测试通常需要 jsdom。
setupFiles 指向一个在执行测试前运行的初始化文件,可以在其中注册全局组件、自定义匹配器或清理 DOM。例如:
import { afterEach } from 'vitest'
import { cleanup } from '@vue/test-utils'
afterEach(() => {
cleanup()
})
最后在 package.json 中添加测试脚本:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
执行 npm run test 即可进入 watch 模式,每次保存文件后自动运行相关测试。
三、组件测试与组合式 API 测试实战
测试 Vue 3 组件时,推荐使用 @vue/test-utils 提供的 mount 函数。假设有一个简单的计数器组件:
<template>
<button @click="increment">Count: {{ count }}</button>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
</script>
对应的测试文件可以这样编写:
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
describe('Counter', () => {
it('renders initial count', () => {
const wrapper = mount(Counter)
expect(wrapper.text()).toContain('Count: 0')
})
it('increments count when button is clicked', async () => {
const wrapper = mount(Counter)
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('Count: 1')
})
})
注意触发 DOM 事件后需要 await wrapper.find('button').trigger('click'),因为 Vue 的响应式更新是异步的,等待触发器完成后再断言可以确保 DOM 已经更新。如果涉及多个异步操作,还可以结合 nextTick 或 flushPromises。
对于组合式 API,可以直接在测试中调用 composable 函数,但需要注意响应式作用域的管理。例如有一个 useCounter:
import { ref } from 'vue'
export function useCounter(initial = 0) {
const count = ref(initial)
function increment() {
count.value++
}
return { count, increment }
}
测试时可以使用 effectScope 来隔离响应式效果,或者直接挂载一个使用该 composable 的测试组件。更简单的方式是借助 @vue/test-utils 的 mount 配合一个内联组件:
import { describe, it, expect } from 'vitest'
import { defineComponent } from 'vue'
import { mount } from '@vue/test-utils'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('should increment count', async () => {
const TestComponent = defineComponent({
setup() {
const { count, increment } = useCounter(5)
return { count, increment }
},
template: '<button @click="increment">{{ count }}</button>'
})
const wrapper = mount(TestComponent)
expect(wrapper.text()).toContain('5')
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('6')
})
})
这段代码通过 defineComponent 创建了一个临时的测试组件,在 setup 中调用 composable 并将返回值暴露给模板,从而能够验证 composable 的行为。这种方式简单直观,适合大多数场景。
四、进阶特性:覆盖率、模拟与持续集成
Vitest 内置了覆盖率收集能力,通过安装 @vitest/coverage-v8 或 @vitest/coverage-istanbul 即可启用。在 vite.config.ts 的 test.coverage 字段中设置 provider 和 reporter 后,运行 vitest run --coverage 会生成文本和 HTML 格式的覆盖率报告。V8 provider 基于 Node 内置的 V8 引擎,速度快且无需额外编译,适合追求效率的团队;Istanbul provider 则提供更细致的分支覆盖率信息。
模拟模块是单元测试中的常见需求,Vitest 提供 vi.mock、vi.spyOn 等 API。例如当组件中使用了 axios 发起网络请求时,可以在测试文件中模拟 axios:
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import UserList from './UserList.vue'
import axios from 'axios'
vi.mock('axios')
describe('UserList', () => {
it('fetches and displays users', async () => {
const users = [{ id: 1, name: 'Alice' }]
vi.mocked(axios.get).mockResolvedValue({ data: users })
const wrapper = mount(UserList)
await flushPromises()
expect(wrapper.text()).toContain('Alice')
})
})
注意 vi.mock('axios') 会将整个模块替换为模拟对象,后续通过 vi.mocked 和 mockResolvedValue 控制返回值。这种方式避免了真实网络请求,让测试稳定且快速。
Vitest 默认使用 worker 线程并行执行测试文件,每个测试文件运行在独立环境中,避免全局变量污染。对于 Vue 组件测试,jsdom 环境在每个 worker 中独立创建,因此不需要担心 DOM 清理不彻底导致的串扰。在持续集成环境中,建议使用 vitest run --coverage 命令,并配合 --reporter=junit 输出 XML 报告,方便 CI 系统收集测试结果。大型项目可以通过 pool: 'forks' 切换到进程池,以应对内存敏感或需要完全隔离的场景。
Vitest 与 Vite 的深度集成还体现在 HMR 测试上。开发过程中,当你修改某个组件或对应的测试文件时,Vitest 的 watch 模式会立即重跑相关用例,并在终端给出清晰的错误信息。这种即时反馈让测试驱动开发在 Vue 3 工程中变得自然流畅,不再有等待构建的割裂感。