Vitest 快速单元测试
一句话概述:Vitest 是基于 Vite 的下一代测试框架,比 Jest 快 10 倍以上,原生支持 ESM 和 TypeScript,共享 Vite 配置,零额外配置就能跑测试。
核心知识点
| 概念 | 白话解释 |
|---|
| Vite-native | 和 Vite 共用同一套模块解析和转换管道 |
| 热更新测试 | 文件改了只重跑受影响的测试,像 HMR 一样快 |
| Jest 兼容 | API 和 Jest 几乎一样,迁移成本极低 |
| 浏览器模式 | 可以在真实浏览器里跑组件测试 |
| 覆盖率 | 内置 v8 和 istanbul 两种覆盖率方案 |
| 工作空间 | 支持 monorepo 多包测试 |
安装配置
# 安装 Vitest
npm install -D vitest # 安装为开发依赖(只要装这一个包!)
# 如果需要覆盖率报告
npm install -D @vitest/coverage-v8 # v8 覆盖率(推荐,更快)
# 如果需要 UI 界面
npm install -D @vitest/ui # 可视化测试界面
配置文件
// vitest.config.ts(或直接写在 vite.config.ts 里)
import { defineConfig } from 'vitest/config' // 导入 vitest 配置
export default defineConfig({
test: {
globals: true, // 全局导入 describe/it/expect,不用每个文件都 import
environment: 'jsdom', // 模拟浏览器环境(测 DOM 用)
// environment: 'node', // Node 环境(测后端代码用)
include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'], // 测试文件匹配模式
coverage: {
provider: 'v8', // 覆盖率引擎
reporter: ['text', 'html', 'json'], // 报告格式
thresholds: { // 覆盖率阈值
lines: 80, // 行覆盖率至少 80%
branches: 80, // 分支覆盖率
functions: 80, // 函数覆盖率
},
},
setupFiles: ['./src/test/setup.ts'], // 测试前运行的配置文件
},
})
package.json 脚本
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui"
}
}
基本使用
编写测试
// src/utils/math.ts - 被测代码
export function add(a: number, b: number): number {
return a + b // 加法
}
export function divide(a: number, b: number): number {
if (b === 0) throw new Error('除数不能为零') // 处理除零
return a / b // 除法
}
export function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1) // 首字母大写
}
// src/utils/math.test.ts - 测试文件
import { describe, it, expect } from 'vitest' // 导入测试函数
import { add, divide, capitalize } from './math' // 导入被测函数
describe('add 加法函数', () => { // describe: 分组
it('1 + 2 应该等于 3', () => { // it: 单个测试
expect(add(1, 2)).toBe(3) // toBe: 精确等于
})
it('负数相加', () => {
expect(add(-1, -2)).toBe(-3)
})
it('零加任何数等于那个数', () => {
expect(add(0, 5)).toBe(5)
})
})
describe('divide 除法函数', () => {
it('10 / 2 应该等于 5', () => {
expect(divide(10, 2)).toBe(5)
})
it('除以零应该抛出错误', () => {
expect(() => divide(10, 0)).toThrow('除数不能为零') // 断言抛出特定错误
})
})
describe('capitalize 首字母大写', () => {
it('hello 变成 Hello', () => {
expect(capitalize('hello')).toBe('Hello')
})
})
运行测试
vitest # 监听模式(文件改了自动重跑)
vitest run # 跑一次就退出
vitest run --coverage # 跑测试并生成覆盖率报告
vitest --ui # 打开浏览器 UI 界面
vitest -t "加法" # 只跑名字包含"加法"的测试
vitest src/utils/ # 只跑这个目录的测试
高级用法
Mock 模拟
import { describe, it, expect, vi } from 'vitest' // vi 是 vitest 的 mock 工具
// Mock 函数
describe('Mock 函数', () => {
it('追踪函数调用', () => {
const fn = vi.fn() // 创建 mock 函数
fn('hello') // 调用它
fn('world')
expect(fn).toHaveBeenCalledTimes(2) // 被调用了 2 次
expect(fn).toHaveBeenCalledWith('hello') // 曾经用 'hello' 调用过
})
it('模拟返回值', () => {
const fn = vi.fn()
.mockReturnValueOnce(1) // 第一次调用返回 1
.mockReturnValueOnce(2) // 第二次返回 2
.mockReturnValue(0) // 之后都返回 0
expect(fn()).toBe(1)
expect(fn()).toBe(2)
expect(fn()).toBe(0)
})
})
// Mock 模块
vi.mock('./db', () => ({ // 模拟整个模块
getUser: vi.fn().mockResolvedValue({ id: 1, name: '张三' }), // 异步 mock
}))
import { getUser } from './db' // 导入的是 mock 版本
it('模拟数据库查询', async () => {
const user = await getUser(1)
expect(user.name).toBe('张三')
})
异步测试
// 测试异步函数
describe('异步操作', () => {
it('async/await 方式', async () => {
const result = await fetchData() // 等待异步结果
expect(result).toEqual({ status: 'ok' }) // toEqual: 深度对比对象
})
it('Promise 方式', () => {
return expect(fetchData()).resolves.toEqual({ status: 'ok' }) // resolves: Promise 成功
})
it('Promise 失败', () => {
return expect(fetchBadData()).rejects.toThrow('网络错误') // rejects: Promise 失败
})
})
快照测试
it('对象快照', () => {
const user = { id: 1, name: '张三', email: 'zhang@test.com' }
expect(user).toMatchSnapshot() // 第一次运行保存快照,之后对比
})
it('内联快照', () => {
expect(capitalize('hello')).toMatchInlineSnapshot('"Hello"') // 快照直接写在测试里
})
参数化测试
import { describe, it, expect } from 'vitest'
// it.each 批量测试不同输入
describe('加法参数化测试', () => {
it.each([
[1, 2, 3], // [输入1, 输入2, 期望结果]
[0, 0, 0],
[-1, 1, 0],
[100, 200, 300],
])('add(%i, %i) = %i', (a, b, expected) => { // %i 是占位符
expect(add(a, b)).toBe(expected)
})
})
// 对象形式(更清晰)
describe.each([
{ input: 'hello', expected: 'Hello' },
{ input: 'world', expected: 'World' },
{ input: 'vitest', expected: 'Vitest' },
])('capitalize("$input")', ({ input, expected }) => {
it(`应该返回 "${expected}"`, () => {
expect(capitalize(input)).toBe(expected)
})
})
定时器模拟
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
describe('定时器测试', () => {
beforeEach(() => {
vi.useFakeTimers() // 启用假定时器
})
afterEach(() => {
vi.restoreAllMocks() // 恢复真实定时器
})
it('延迟函数', () => {
const callback = vi.fn()
setTimeout(callback, 1000) // 1 秒后调用
vi.advanceTimersByTime(999) // 快进 999ms
expect(callback).not.toHaveBeenCalled() // 还没到时间
vi.advanceTimersByTime(1) // 再快进 1ms
expect(callback).toHaveBeenCalledOnce() // 现在调用了
})
})
常见报错
| 报错信息 | 原因 | 解决方案 |
|---|
Cannot find module | 模块解析失败 | 检查 vite.config.ts 的 alias 配置 |
ReferenceError: describe is not defined | 没开全局模式 | 配置 globals: true 或 import 它们 |
document is not defined | 测 DOM 没配环境 | 设 environment: 'jsdom' |
| 覆盖率全是 0 | 没装覆盖率包 | npm install -D @vitest/coverage-v8 |
vi is not defined | 没导入 vi | import { vi } from 'vitest' |
| Mock 不生效 | mock 位置不对 | vi.mock() 必须写在文件顶层 |
速查表
# CLI 命令
vitest # 监听模式
vitest run # 运行一次
vitest run --coverage # 带覆盖率
vitest --ui # UI 界面
vitest -t "测试名" # 按名称过滤
vitest --reporter=verbose # 详细输出
vitest bench # 性能基准测试
# 常用断言
expect(x).toBe(y) # 严格相等(===)
expect(x).toEqual(y) # 深度对比
expect(x).toBeTruthy() # 真值
expect(x).toBeFalsy() # 假值
expect(x).toBeNull() # null
expect(x).toBeUndefined() # undefined
expect(x).toBeGreaterThan(y) # 大于
expect(x).toContain(item) # 包含(数组/字符串)
expect(fn).toThrow() # 抛出错误
expect(fn).toHaveBeenCalled() # 被调用过
expect(x).toMatchSnapshot() # 快照匹配
# Mock 工具
vi.fn() # 创建 mock 函数
vi.mock('./module') # 模拟整个模块
vi.spyOn(obj, 'method') # 监视方法调用
vi.useFakeTimers() # 假定时器
vi.advanceTimersByTime(ms) # 快进时间
vi.restoreAllMocks() # 恢复所有 mock
参考:Vitest 官网 | GitHub | 入门指南