Vitest 单元测试入门
1. Vitest 和 JUnit 的对照
写 Java 你已经熟悉 JUnit:
@Test
void shouldAddTwoNumbers() {
assertEquals(3, calculator.add(1, 2));
}
Vitest 几乎是同一套心智:
import { describe, it, expect } from 'vitest'
describe('calculator', () => {
it('shouldAddTwoNumbers', () => {
expect(add(1, 2)).toBe(3)
})
})
关键词映射:
| JUnit | Vitest |
|---|---|
@Test |
it('...', () => {}) |
assertEquals(a, b) |
expect(a).toBe(b) |
assertTrue(cond) |
expect(cond).toBe(true) |
@BeforeEach |
beforeEach(() => {}) |
@AfterEach |
afterEach(() => {}) |
@Mock |
vi.fn() / vi.mock() |
| Maven surefire 插件 | vitest run 命令 |
| 测试类 | describe('...') 分组 |
为什么选 Vitest:
- 和 Vite 共享配置(配过
vite.config.ts就会配 Vitest) - 原生支持 TS、ESM、JSX/Vue
- 比 Jest 快(不转译、原生 ESM)
- API 和 Jest 兼容,老 Jest 项目迁移成本极低
2. 安装和第一个测试
pnpm add -D vitest @vue/test-utils jsdom
package.json 加脚本:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage"
}
}
脚本说明:
test—— 监听模式,文件变化自动重跑(开发时用)test:run—— 跑一遍退出(CI 用)test:ui—— 浏览器里可视化看测试(记得pnpm add -D @vitest/ui)test:coverage—— 生成覆盖率报告(装@vitest/coverage-v8)
配置 vitest.config.ts(或复用 vite.config.ts):
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
globals: true, // 不用每个文件都 import describe/it/expect
environment: 'jsdom', // 模拟浏览器 API
setupFiles: ['./src/__tests__/setup.ts'],
},
})
environment 选项:
node—— 纯 Node 环境(测工具函数够用)jsdom—— 模拟浏览器 DOM(测组件或用到window/document的代码)happy-dom—— 更快的浏览器模拟(轻量替代)
3. 测试文件放哪
两种惯例:
A. 和源文件同级 *.spec.ts
src/
├── utils/
│ ├── format.ts
│ └── format.spec.ts ← 测试和源码贴身
优点:改代码时看到测试就近,改漏率低。
缺点:src/ 目录里测试和源码混在一起,翻文件略多。
B. 集中在 __tests__/
src/
├── __tests__/
│ ├── utils/
│ │ └── format.spec.ts
│ └── composables/
│ └── useCounter.spec.ts
├── utils/
│ └── format.ts
优点:测试目录独立,路径清晰。 缺点:改业务时容易忘了去更新对应测试。
推荐 A:工具函数和 composables 用 .spec.ts 就近放;集成测试或 e2e 再走独立目录。in4vue 用 A 方式。
4. 基本断言速查
import { describe, it, expect } from 'vitest'
describe('basic assertions', () => {
it('相等判断', () => {
expect(1 + 1).toBe(2) // 基本类型严格相等
expect({ a: 1 }).toEqual({ a: 1 }) // 对象深度相等
expect({ a: 1 }).not.toBe({ a: 1 }) // toBe 对对象是引用比较
})
it('布尔/空值', () => {
expect(true).toBeTruthy()
expect(0).toBeFalsy()
expect(null).toBeNull()
expect(undefined).toBeUndefined()
expect('abc').toBeDefined()
})
it('数值比较', () => {
expect(10).toBeGreaterThan(5)
expect(0.1 + 0.2).toBeCloseTo(0.3) // 浮点数要用 close
})
it('字符串', () => {
expect('hello').toContain('ell')
expect('hello').toMatch(/^h.+o$/)
})
it('数组', () => {
expect([1, 2, 3]).toContain(2)
expect([1, 2, 3]).toHaveLength(3)
})
it('对象', () => {
expect({ a: 1, b: 2 }).toHaveProperty('a', 1)
expect({ a: 1, b: 2 }).toMatchObject({ a: 1 }) // 部分匹配
})
it('异常', () => {
expect(() => { throw new Error('oops') }).toThrow('oops')
})
})
.not 任何断言前都能加:expect(x).not.toBe(y)
Java 对照:
toBe≈assertSame(引用相等)toEqual≈assertEquals(值相等)toStrictEqual≈ AssertJ 的.isEqualTo(包含 undefined 字段、类型检查)
5. 测工具函数(最容易上手)
// src/utils/format.ts
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
export function truncate(str: string, max: number): string {
return str.length <= max ? str : str.slice(0, max) + '...'
}
// src/utils/format.spec.ts
import { describe, it, expect } from 'vitest'
import { formatFileSize, truncate } from './format'
describe('formatFileSize', () => {
it('小于 1KB 显示 B', () => {
expect(formatFileSize(512)).toBe('512 B')
})
it('小于 1MB 显示 KB', () => {
expect(formatFileSize(2048)).toBe('2.0 KB')
})
it('大于 1MB 显示 MB', () => {
expect(formatFileSize(1024 * 1024 * 5)).toBe('5.0 MB')
})
it('边界值: 0', () => {
expect(formatFileSize(0)).toBe('0 B')
})
})
describe('truncate', () => {
it.each([
['hello', 5, 'hello'], // 正好等于 max 不截
['hello', 10, 'hello'], // 小于 max 不截
['hello world', 5, 'hello...'],
])('truncate(%s, %d) = %s', (input, max, expected) => {
expect(truncate(input, max)).toBe(expected)
})
})
it.each 写参数化测试:一个用例 N 组数据,类比 JUnit 的 @ParameterizedTest + @CsvSource。
6. 测 composables(异步 + 响应式)
in4vue 的典型 composable:
// src/composables/useCounter.ts
import { ref, computed } from 'vue'
export function useCounter(initial = 0, step = 1) {
const count = ref(initial)
const doubled = computed(() => count.value * 2)
const increment = () => { count.value += step }
const decrement = () => { count.value -= step }
const reset = () => { count.value = initial }
return { count, doubled, increment, decrement, reset }
}
测试要在 Vue 的响应式系统里运行:
// src/composables/useCounter.spec.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('初始值默认为 0', () => {
const { count } = useCounter()
expect(count.value).toBe(0)
})
it('increment 加一', () => {
const { count, increment } = useCounter()
increment()
expect(count.value).toBe(1)
})
it('自定义步长', () => {
const { count, increment } = useCounter(10, 5)
increment()
increment()
expect(count.value).toBe(20)
})
it('doubled 是 count 的两倍', () => {
const { count, doubled, increment } = useCounter()
increment() // 1
expect(doubled.value).toBe(2)
increment() // 2
expect(doubled.value).toBe(4)
})
it('reset 恢复初始值', () => {
const { count, increment, reset } = useCounter(5)
increment()
increment()
expect(count.value).toBe(7)
reset()
expect(count.value).toBe(5)
})
})
注意:读响应式值要用 count.value,模板里自动解包在测试里不行。
7. 带 Pinia 的 composable
用了 Pinia 的 composable 要先初始化 Pinia:
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useUserStore } from './user'
describe('user store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('初始未登录', () => {
const store = useUserStore()
expect(store.isLoggedIn).toBe(false)
})
it('setToken 后变为已登录', () => {
const store = useUserStore()
store.setToken('abc')
expect(store.isLoggedIn).toBe(true)
})
it('logout 清空 token 和用户信息', () => {
const store = useUserStore()
store.setToken('abc')
store.setInfo({ id: 1, username: 'test', roles: [], permissions: [] })
store.logout()
expect(store.token).toBe('')
expect(store.info).toBeNull()
})
})
Java 对照:类似 Spring Test 的 @SpringBootTest 初始化上下文——测试前启动依赖注入容器,每个测试一个干净实例。
8. Mock:切断外部依赖
测试一个调接口的函数,不能真的请求后端。vi.fn() / vi.mock() 上场:
8.1 mock 单个函数
import { describe, it, expect, vi } from 'vitest'
const callback = vi.fn()
callback('hello')
callback('world')
expect(callback).toHaveBeenCalledTimes(2)
expect(callback).toHaveBeenCalledWith('hello')
expect(callback).toHaveBeenLastCalledWith('world')
vi.fn() 是一个"可追踪调用"的函数,类似 Mockito 的 mock()。
8.2 mock 模块
// src/composables/useNotes.ts
import { noteApi } from '@/api/note'
export async function loadNotes() {
const res = await noteApi.page({ page: 1, pageSize: 10 })
return res.list
}
// src/composables/useNotes.spec.ts
import { describe, it, expect, vi } from 'vitest'
import { loadNotes } from './useNotes'
// 替换整个模块
vi.mock('@/api/note', () => ({
noteApi: {
page: vi.fn().mockResolvedValue({
list: [{ id: 1, title: '测试笔记' }],
total: 1,
}),
},
}))
describe('loadNotes', () => {
it('返回笔记列表', async () => {
const notes = await loadNotes()
expect(notes).toHaveLength(1)
expect(notes[0].title).toBe('测试笔记')
})
})
Java 对照:@MockBean 替换 Spring 里的 bean。
8.3 定时器 mock
代码里用了 setTimeout,测试里等 3 秒太傻:
import { describe, it, expect, vi } from 'vitest'
describe('debounce', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('300ms 后触发', () => {
const fn = vi.fn()
const debounced = debounce(fn, 300)
debounced()
expect(fn).not.toHaveBeenCalled() // 还没到时间
vi.advanceTimersByTime(300) // 快进 300ms
expect(fn).toHaveBeenCalledTimes(1)
})
})
vi.advanceTimersByTime(ms) 瞬间"跳到未来"。JUnit 里一般用 Awaitility 等真实时间,Vitest 的假时钟更快更稳。
9. 异步测试
it('async/await', async () => {
const data = await fetchData()
expect(data).toHaveProperty('id')
})
it('Promise', () => {
return fetchData().then((data) => {
expect(data).toHaveProperty('id')
})
})
it('期待抛异常', async () => {
await expect(fetchUser(-1)).rejects.toThrow('Invalid id')
})
it('期待不抛', async () => {
await expect(fetchUser(1)).resolves.toHaveProperty('name')
})
注意:异步测试忘了 await 或 return 是常见坑——测试会"秒过"但断言没跑,骗你一脸绿。
10. 生命周期钩子
describe('示例', () => {
beforeAll(() => {
// 整个 describe 执行前 1 次
})
afterAll(() => {
// 整个 describe 执行后 1 次
})
beforeEach(() => {
// 每个 it 前执行(复位测试状态)
})
afterEach(() => {
// 每个 it 后执行(清理)
})
it('case 1', () => { /* ... */ })
it('case 2', () => { /* ... */ })
})
常见用法:
beforeEach重新初始化 Pinia、清空 mockafterEach恢复 fake timers、清 localStorage
11. 覆盖率
pnpm add -D @vitest/coverage-v8
pnpm test:coverage
输出报告:
File | % Stmts | % Branch | % Funcs | % Lines |
---------------|---------|----------|---------|---------|
All files | 85.71 | 75.00 | 100.00 | 85.71 |
format.ts | 95.00 | 87.50 | 100.00 | 95.00 |
不要盲目追求 100%:
- UI 逻辑(弹窗、toast)测起来价值低
- 框架模板代码不用测
- 工具函数、composables、业务逻辑的覆盖率目标可设 80%+
覆盖率配置:
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
include: ['src/**/*.{ts,vue}'],
exclude: [
'src/**/*.spec.ts',
'src/main.ts',
'src/router/**',
'src/types/**',
],
},
},
})
html 报告在 coverage/index.html,开了能看到哪几行没测到,直观。
12. 实用技巧
12.1 it.only / describe.only 聚焦
it.only('只跑这个', () => { /* ... */ })
其他用例会被跳过,临时调试某个失败用例时用。提交前记得去掉——否则 CI 会因为"只跑了一个"而报警。
12.2 it.skip 跳过
it.skip('暂时不跑', () => { /* ... */ })
比 // it(...) 强,跳过的原因一眼看到。
12.3 it.todo 写占位
it.todo('需要测登录失败的场景')
运行时显示"TODO",提醒自己还没写。
12.4 快照测试
it('配置对象不变', () => {
expect(buildConfig()).toMatchSnapshot()
})
首次运行会在 __snapshots__/ 目录生成快照文件,下次运行和快照比对。适合:配置对象、长字符串、生成的 HTML。
13. 心智模型:该测什么
高价值测试:
✅ 工具函数 (纯函数,输入→输出,好测)
✅ composables 的业务逻辑
✅ Pinia store 的 action
✅ 格式化/校验/计算
中价值:
🟡 组件的关键交互 (用 Vue Test Utils,下一篇)
🟡 Axios 拦截器逻辑
低价值 (先别测):
❌ 静态模板渲染
❌ 简单 CRUD 请求(接口稳定时不值得 mock)
❌ 样式和视觉呈现
TDD 还是测试后补:
- 工具函数、纯算法推荐 TDD(先写测试再写实现)
- 业务组件先实现再补关键测试
- 修 bug 时先写一个能复现 bug 的测试,再修——下次不会再回归
Java 对照:和后端一样,Service 层价值最高,Controller 层薄到不值得测(集成测试覆盖)。
14. 常见坑点
| 现象 | 原因 | 解法 |
|---|---|---|
| 测试 hang 住不结束 | 异步没 await / 没用 fake timers | 检查 async / 加 vi.useFakeTimers |
Cannot find module '@/...' |
tsconfig paths 没同步到 Vitest | vitest.config.ts 里加 resolve.alias |
| mock 不生效 | vi.mock 被变量引用,提升失败 |
用 vi.hoisted 或直接写字面量 |
| Pinia 报 "no active pinia" | 测试里没初始化 | beforeEach(setActivePinia(createPinia())) |
window is not defined |
环境是 node | 改 environment: 'jsdom' |
| 覆盖率包含了不该算的文件 | 默认包含全部 | 配置 coverage.exclude |
小练习
- 装 Vitest,给
src/utils/format.ts写 3 个测试 - 给
useUserStore的logout方法写测试 - 用
vi.mockmocknoteApi.page,测一个调它的 composable - 用
it.each写参数化测试(防抖、节流的边界值) - 开覆盖率,看哪些分支没测到
延伸阅读
- Vitest 官方文档
- Vitest API 参考
- Vue Test Utils(下一篇会详细讲)
- Testing Pinia
- 前端测试金字塔(Martin Fowler 的经典文章)