Vitest 3 实战指南:从 Jest 迁移到极致测试体验

深入解析 Vitest 测试框架的核心优势、配置技巧与实战用法。涵盖 Vite 集成、快照测试、Mock 策略、覆盖率配置,以及从 Jest 迁移的完整指南,帮助开发者构建高效的测试体系。

前端开发 2026-06-10 12 分钟

如果你还在用 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)expectTypeOfassertType 实现编译期类型断言
  • 内联工作线程(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 项目迁移:按以下优先级推进:

  1. 先在新模块中使用 Vitest(Vitest 和 Jest 可以共存)
  2. 用自动化脚本批量替换 jestvi
  3. 运行全量测试,修复不兼容的 Snapshot
  4. 移除 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 倍的等待时间。

📚 相关文章