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


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"
  }
}

脚本说明

配置 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 选项


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 对照


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')
})

注意:异步测试忘了 awaitreturn 是常见坑——测试会"秒过"但断言没跑,骗你一脸绿。


10. 生命周期钩子

describe('示例', () => {
  beforeAll(() => {
    // 整个 describe 执行前 1 次
  })
  afterAll(() => {
    // 整个 describe 执行后 1 次
  })
  beforeEach(() => {
    // 每个 it 前执行(复位测试状态)
  })
  afterEach(() => {
    // 每个 it 后执行(清理)
  })

  it('case 1', () => { /* ... */ })
  it('case 2', () => { /* ... */ })
})

常见用法


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%

覆盖率配置

// 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 还是测试后补

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

小练习

  1. 装 Vitest,给 src/utils/format.ts 写 3 个测试
  2. useUserStorelogout 方法写测试
  3. vi.mock mock noteApi.page,测一个调它的 composable
  4. it.each 写参数化测试(防抖、节流的边界值)
  5. 开覆盖率,看哪些分支没测到

延伸阅读