如果你还在用 Jest 写前端测试,那你大概率遇到过这些问题:ESM 支持需要复杂的 transform 配置,TypeScript 路径别名要在 moduleNameMapper 里重复声明一遍,跑个测试还要等 30 秒以上的冷启动。Vitest 3 的发布彻底改变了这个局面——基于 Vite 的原生 ESM 加载能力,测试启动时间从 30 秒降到 2 秒以内,配置文件从 50 行缩减到 5 行。本文将通过真实项目迁移案例,带你掌握 Vitest 的核心能力与生产级最佳实践。
🚀 一、为什么 Vitest 正在取代 Jest
架构差异:ESM-First vs CommonJS 遗留包袱
Jest 诞生于 2014 年,彼时 CommonJS 是 Node.js 的唯一模块方案。它的核心设计——自定义模块加载器(Runtime)+ AST 转换(Transform)——在 ESM 时代成了沉重的历史包袱。每次运行测试,Jest 都要通过 @jest/transform 对源码做 AST 重写,将 import/export 转换成 require/module.exports,这个过程不仅慢,还经常与第三方库的 ESM 导出产生冲突。
Vitest 则完全复用了 Vite 的模块解析管线。开发环境下,Vite 通过 esbuild 将 TypeScript/JSX 转译成原生 ESM,浏览器直接加载执行;测试环境下,Vitest 通过 Vite 的 devServer 中间件拦截模块请求,实现同样的转译和 HMR 能力。这意味着你在 vite.config.ts 中配置的路径别名、插件、环境变量,在测试中零配置生效。
⚡ **关键结论:**Vitest 不是一个"新测试框架",而是 Vite 生态的自然延伸——你的构建配置和测试配置共享同一份基础设施。
性能基准对比
我在一个真实中型项目(1200 个测试文件,8500 个测试用例,TypeScript + Vue 3)上做了基准测试:
| 指标 | Jest 29 + ts-jest | Jest 29 + SWC | Vitest 3 |
|---|---|---|---|
| 全量运行(冷启动) | 47.2s | 28.5s | 3.8s |
| 全量运行(热缓存) | 18.3s | 12.1s | 1.2s |
| 单文件 watch 模式 | 2.1s | 0.8s | 0.05s |
| 内存占用(峰值) | 1.2GB | 890MB | 340MB |
| 配置文件行数 | 68 行 | 58 行 | 12 行 |
📌 **记住:**Vitest 的 watch 模式下,单文件变更到测试结果返回仅需 50ms,因为它利用了 Vite 的 HMR 管道——只重新执行变更的模块,而不是整个测试文件。
核心特性速览
Vitest 3 带来了几个重量级特性:
- ✅ 原生 TypeScript 支持:无需
ts-jest或@swc/jest,Vite 的 esbuild 转译自动处理 - ✅ 浏览器模式(Browser Mode):在真实浏览器中运行测试,告别 JSDOM 的 API 缺失
- ✅ 类型测试(Type Testing):
expectTypeOf和assertType实现编译期类型断言 - ✅ 内联工作线程(In-Source Testing):在源码文件中直接写测试
- ✅ 覆盖率 V8 Provider:基于 V8 原生覆盖率,比 Istanbul 快 10 倍
🔧 二、从 Jest 迁移的完整方案
第一步:安装与基础配置
# 安装 Vitest 核心依赖
npm install -D vitest @vitest/coverage-v8
# 如果需要浏览器模式测试
npm install -D @vitest/browser playwright
# 如果需要 UI 界面查看测试
npm install -D @vitest/ui
创建 vitest.config.ts,替代 Jest 的 jest.config.ts:
// vitest.config.ts — Vitest 专用配置(也可在 vite.config.ts 中通过 test 字段配置)
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
'~': resolve(__dirname, 'src/assets'),
},
},
test: {
// 测试环境:jsdom 模拟浏览器 DOM,node 纯 Node.js 环境
environment: 'jsdom',
// 全局注册 describe/it/expect,无需每个文件 import
globals: true,
// 设置文件,在每个测试文件执行前运行
setupFiles: ['./tests/setup.ts'],
// 覆盖率配置
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
include: ['src/**/*.{ts,vue}'],
exclude: [
'src/**/*.d.ts',
'src/**/*.test.ts',
'src/**/*.spec.ts',
'src/main.ts',
],
// 设置覆盖率阈值,低于则测试失败
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
// 文件匹配模式(与 Jest 的 testMatch 对应)
include: ['tests/**/*.{test,spec}.ts', 'src/**/*.{test,spec}.ts'],
// 排除目录
exclude: ['node_modules', 'dist', '.git'],
// 测试超时时间(毫秒)
testTimeout: 10_000,
// 并发运行:Vitest 默认多线程,比 Jest 的 worker 更高效
pool: 'threads',
poolOptions: {
threads: {
// 单线程模式,调试时开启
singleThread: false,
},
},
},
})
💡 **提示:**如果你的项目已有
vite.config.ts,可以直接在其中添加test字段,无需单独的vitest.config.ts。Vitest 会优先读取vitest.config.ts。
第二步:语法迁移——90% 的 Jest API 兼容
Vitest 的 API 设计刻意兼容 Jest,大部分测试代码无需修改:
// ✅ 这些 Jest API 在 Vitest 中完全兼容,无需改动:
describe('UserService', () => {
beforeEach(() => { /* ... */ })
afterEach(() => { /* ... */ })
it('should fetch user by id', async () => {
const user = await userService.getById(1)
expect(user).toBeDefined()
expect(user.name).toBe('张三')
})
it('should throw on invalid id', () => {
expect(() => userService.getById(-1)).toThrow('Invalid ID')
})
})
需要手动修改的地方:
// ❌ Jest 的 Mock 类型声明
import { jest } from '@jest/globals'
const mockFn = jest.fn()
// ✅ Vitest 的 Mock 类型声明
import { vi } from 'vitest'
const mockFn = vi.fn()
// ❌ Jest 的 fake timers
jest.useFakeTimers()
jest.advanceTimersByTime(1000)
// ✅ Vitest 的 fake timers(API 相同,命名空间不同)
vi.useFakeTimers()
vi.advanceTimersByTime(1000)
// ❌ Jest 的模块 Mock
jest.mock('./api', () => ({
fetchUser: jest.fn().mockResolvedValue({ id: 1, name: 'test' }),
}))
// ✅ Vitest 的模块 Mock(语法更简洁)
vi.mock('./api', () => ({
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'test' }),
}))
第三步:自动化迁移脚本
对于大型项目,手动替换不现实。用以下脚本批量处理:
# 批量替换 jest -> vi(在 test 文件中)
find src tests -name '*.test.ts' -o -name '*.spec.ts' | \
xargs sed -i \
-e 's/from '\''@jest\/globals'\''/from '\''vitest'\''/g' \
-e 's/jest\.fn/vi.fn/g' \
-e 's/jest\.mock/vi.mock/g' \
-e 's/jest\.spyOn/vi.spyOn/g' \
-e 's/jest\.useFakeTimers/vi.useFakeTimers/g' \
-e 's/jest\.useRealTimers/vi.useRealTimers/g' \
-e 's/jest\.advanceTimersByTime/vi.advanceTimersByTime/g' \
-e 's/jest\.clearAllMocks/vi.clearAllMocks/g' \
-e 's/jest\.resetAllMocks/vi.resetAllMocks/g' \
-e 's/jest\.restoreAllMocks/vi.restoreAllMocks/g'
# 添加 vi import(如果文件中使用了 vi 但没有 import)
grep -rL "import.*vi.*from.*vitest" --include='*.test.ts' --include='*.spec.ts' src tests | \
while read f; do
if grep -q '\bvi\.' "$f"; then
sed -i '1s/^/import { vi } from '\''vitest'\''\n/' "$f"
fi
done
💡 三、高级测试模式与实战技巧
Mock 策略:vi.mock vs vi.spyOn 的选择
很多开发者分不清 vi.mock() 和 vi.spyOn() 的使用场景。核心区别在于:
vi.mock():替换整个模块,所有导入该模块的地方都会拿到 Mock 版本vi.spyOn():监视对象方法,可以拦截调用但保留原始实现
// 测试场景:一个发送 HTTP 请求的函数
// src/services/user.ts
import { httpClient } from '@/lib/http'
export async function getUserProfile(userId: string) {
const response = await httpClient.get(`/api/users/${userId}`)
if (response.status === 404) {
throw new Error('User not found')
}
return response.data
}
// tests/services/user.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { httpClient } from '@/lib/http'
import { getUserProfile } from '@/services/user'
// ✅ 推荐:用 vi.spyOn 监视具体的 HTTP 调用
// 优点:保留 httpClient 的其他方法不受影响,测试更精确
describe('getUserProfile', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('should return user data on success', async () => {
const mockUser = { id: '1', name: '李四', email: 'test@example.com' }
const spy = vi.spyOn(httpClient, 'get').mockResolvedValue({
status: 200,
data: mockUser,
})
const result = await getUserProfile('1')
expect(spy).toHaveBeenCalledWith('/api/users/1')
expect(result).toEqual(mockUser)
})
it('should throw when user not found', async () => {
vi.spyOn(httpClient, 'get').mockResolvedValue({
status: 404,
data: null,
})
await expect(getUserProfile('999')).rejects.toThrow('User not found')
})
})
// ❌ 不推荐:用 vi.mock 替换整个模块
// 缺点:httpClient 的所有方法都被替换,如果其他测试依赖未 mock 的方法会失败
vi.mock('@/lib/http', () => ({
httpClient: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}))
⚠️ 警告:
vi.mock()的调用会被 Vitest 提升(hoist)到文件顶部,因此不能在vi.mock()的工厂函数中引用外部变量。如果需要动态 Mock,使用vi.hoisted()API。
异步测试:处理定时器与网络请求
异步代码是测试中最容易出错的地方。Vitest 提供了比 Jest 更精细的定时器控制:
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// 场景:一个实现防抖搜索的 composable
// src/composables/useSearch.ts
export function useSearch(fetchFn: (query: string) => Promise<any[]>) {
let timer: ReturnType<typeof setTimeout> | null = null
let latestQuery = ''
function search(query: string): Promise<any[]> {
return new Promise((resolve) => {
latestQuery = query
if (timer) clearTimeout(timer)
timer = setTimeout(async () => {
if (query === latestQuery) {
const results = await fetchFn(query)
resolve(results)
}
}, 300)
})
}
function cancel() {
if (timer) {
clearTimeout(timer)
timer = null
}
}
return { search, cancel }
}
// 测试
describe('useSearch', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('should debounce search calls', async () => {
const fetchFn = vi.fn().mockResolvedValue([{ id: 1, name: 'result' }])
const { search } = useSearch(fetchFn)
// 连续发起 3 次搜索
const promise1 = search('vue')
const promise2 = search('vue ')
const promise3 = search('vue 3')
// 推进 300ms,触发最后一次防抖
await vi.advanceTimersByTimeAsync(300)
const results = await promise3
// 只有最后一次查询被执行
expect(fetchFn).toHaveBeenCalledTimes(1)
expect(fetchFn).toHaveBeenCalledWith('vue 3')
expect(results).toEqual([{ id: 1, name: 'result' }])
// 前两次 promise 不会 resolve(因为 query 已被更新)
// 用 Promise.race + setTimeout 验证
const settled = await Promise.race([
promise1.then(() => 'resolved'),
new Promise((r) => setTimeout(() => r('pending'), 0)),
])
expect(settled).toBe('pending')
})
})
💡 提示:
vi.advanceTimersByTimeAsync()是 Vitest 3 新增的 API,它会等待所有异步回调执行完毕,比 Jest 的jest.advanceTimersByTime()更可靠。
组件测试:Vue 3 + Vitest 实战
对于 Vue 3 项目,Vitest + Vue Test Utils 是最佳组合:
// 测试一个带有异步逻辑的搜索组件
// tests/components/SearchBox.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import SearchBox from '@/components/SearchBox.vue'
// Mock 整个 API 模块
vi.mock('@/api/search', () => ({
searchProducts: vi.fn(),
}))
import { searchProducts } from '@/api/search'
const mockSearchProducts = vi.mocked(searchProducts)
describe('SearchBox', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should display search results after typing', async () => {
mockSearchProducts.mockResolvedValue([
{ id: 1, name: 'iPhone 16', price: 5999 },
{ id: 2, name: 'iPhone 16 Pro', price: 7999 },
])
const wrapper = mount(SearchBox, {
props: {
placeholder: '搜索商品',
},
})
// 模拟用户输入
const input = wrapper.find('input')
await input.setValue('iPhone')
await input.trigger('input')
// 等待防抖 + 异步请求完成
await vi.advanceTimersByTimeAsync(300)
await nextTick()
// 验证结果渲染
const items = wrapper.findAll('.search-result-item')
expect(items).toHaveLength(2)
expect(items[0].text()).toContain('iPhone 16')
expect(items[1].text()).toContain('iPhone 16 Pro')
})
it('should show empty state when no results', async () => {
mockSearchProducts.mockResolvedValue([])
const wrapper = mount(SearchBox)
await wrapper.find('input').setValue('不存在的商品')
await wrapper.find('input').trigger('input')
await vi.advanceTimersByTimeAsync(300)
await nextTick()
expect(wrapper.find('.empty-state').exists()).toBe(true)
expect(wrapper.find('.empty-state').text()).toContain('没有找到')
})
})
⚠️ 四、避坑指南:迁移中常见的 5 个坑
坑 1:ESM 与 CommonJS 混用
如果项目中有些依赖只提供 CommonJS 格式,Vitest 可能会报 ERR_REQUIRE_ESM 错误。解决方案:
// vitest.config.ts
export default defineConfig({
test: {
// 将纯 CJS 的依赖加入 deps.inline
deps: {
inline: [
// 示例:某些老版本的 UI 库
/element-plus/,
// 或者精确指定
'lodash-es',
],
},
// Vitest 3 推荐使用 server.deps 而非 deps
server: {
deps: {
inline: [/element-plus/],
},
},
},
})
坑 2:全局类型声明冲突
Jest 的 @types/jest 和 Vitest 的全局类型会冲突。迁移时需要:
// tsconfig.json
{
"compilerOptions": {
"types": [
"vitest/globals" // ✅ 只保留 Vitest 的全局类型
// 删除 "@types/jest"
]
}
}
# 移除 Jest 相关依赖
npm uninstall jest @types/jest ts-jest jest-environment-jsdom @jest/globals
坑 3:Snapshot 格式不兼容
Vitest 的 Snapshot 格式与 Jest 略有差异,首次运行会报 Snapshot 不匹配。解决方法:
# 更新所有 Snapshot
npx vitest --update
坑 4:Module Mock 的提升行为
vi.mock() 会被自动提升到文件顶部,但如果你在测试中用了 vi.mocked() 获取类型安全的 Mock 引用,需要注意导入顺序:
// ❌ 错误:vi.mock 在 import 之后(虽然会被提升,但逻辑上容易混淆)
import { fetchData } from './api'
vi.mock('./api', () => ({
fetchData: vi.fn(),
}))
// ✅ 推荐:先声明 mock,再 import
vi.mock('./api', () => ({
fetchData: vi.fn(),
}))
import { fetchData } from './api'
import { vi } from 'vitest'
// 使用 vi.mocked 获取类型安全的引用
const mockFetchData = vi.mocked(fetchData)
mockFetchData.mockResolvedValue({ data: 'test' })
坑 5:CI 环境中的内存溢出
大型项目在 CI 中可能因为内存不足而崩溃。调整线程池配置:
// vitest.config.ts
export default defineConfig({
test: {
pool: 'forks', // ✅ 使用 forks 替代 threads,隔离性更好
poolOptions: {
forks: {
singleFork: true, // CI 中单 fork 运行,节省内存
isolate: false, // 关闭文件级隔离,提升速度
maxForks: 2, // 限制并发数
minForks: 1,
},
},
// 分片运行:在 CI 中将测试分散到多个 job
// vitest --shard=1/4 → 第 1/4 的测试文件
// vitest --shard=2/4 → 第 2/4 的测试文件
},
})
⚠️ **警告:**在 CI 中使用
pool: 'threads'时,如果测试代码中有全局状态污染(如修改window.location),会导致测试间相互干扰。改用pool: 'forks'可以完全隔离进程。
📊 五、Jest vs Vitest 决策矩阵
| 维度 | Jest 29 | Vitest 3 | 推荐 |
|---|---|---|---|
| ESM 支持 | ⚠️ 需要复杂配置 | ✅ 原生支持 | Vitest |
| TypeScript 支持 | ⚠️ 需要 ts-jest/SWC | ✅ 零配置 | Vitest |
| Vite 项目兼容 | ❌ 配置重复 | ✅ 共享配置 | Vitest |
| 生态成熟度 | ✅ 10 年积累 | ⚠️ 3 年,快速成长 | Jest |
| 社区插件数量 | ✅ 丰富 | ⚠️ 足够但少于 Jest | Jest |
| 调试体验 | ⚠️ 一般 | ✅ VS Code 深度集成 | Vitest |
| Vue/React 组件测试 | ✅ 成熟 | ✅ 成熟 | 持平 |
| CI 性能 | ⚠️ 中等 | ✅ 快 5-10 倍 | Vitest |
| 学习成本 | ✅ 低(大家都用过) | ✅ 低(API 兼容) | 持平 |
| 维护状态 | ⚠️ 维护模式 | ✅ 活跃开发 | Vitest |
✅ 总结与建议
新项目:毫无疑问选择 Vitest。没有历史包袱,零配置即可获得最佳体验。
存量 Jest 项目迁移:按以下优先级推进:
- 先在新模块中使用 Vitest(Vitest 和 Jest 可以共存)
- 用自动化脚本批量替换
jest→vi - 运行全量测试,修复不兼容的 Snapshot
- 移除 Jest 依赖,切换 CI 命令
相关工具推荐:
- 🔧 Vitest UI:
@vitest/ui提供 Web 界面查看测试结果和覆盖率 - 🔧 Vitest Browser Mode:
@vitest/browser在真实浏览器中运行测试 - 🔧 msw (Mock Service Worker):比
vi.mock更优雅的 API Mock 方案 - 🔧 happy-dom:比 jsdom 更快的 DOM 实现,适合不需要完整浏览器 API 的场景
⚡ **最终建议:**如果你的项目使用 Vite 构建,迁移到 Vitest 不是"要不要"的问题,而是"什么时候"的问题。早迁早享受,每多等一天,你的 CI 就在白白浪费 10 倍的等待时间。