📖 文章概述
单元测试是代码质量的保障,Vitest 和 Jest 是现代 JavaScript 测试的两大主流框架。本文深入讲解其核心用法、测试模式和实战技巧。
🎯 测试的价值
为什么需要单元测试?
| 指标 | 没有测试 | 有测试 |
|---|---|---|
| 缺陷发现时间 | 生产环境 | 开发阶段 |
| 重构风险 | 高 | 低 |
| 维护成本 | 高 | 低 |
| 代码质量 | 中等 | 优秀 |
| 开发速度 | 快 | 稳定 |
测试金字塔
╱╲
╱ ╲ 端到端测试 (E2E)
╱────╲ ≈ 5-10 个
╱ I/O ╲ 集成测试 (Integration)
╱────────╲ ≈ 20-30 个
╱ Unit ╲ 单元测试 (Unit)
╱──────────╲ ≈ 70-80 个
🚀 Vitest 快速开始
1. Vitest 安装和配置
# 安装 Vitest
npm install -D vitest
# 创建 vitest.config.ts
touch vitest.config.ts
vitest.config.ts 基础配置
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
test: {
// 测试环境
environment: 'jsdom',
// 全局 API(无需导入)
globals: true,
// 覆盖率配置
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'dist/',
]
},
// 路径别名
alias: {
'@': path.resolve(__dirname, './src')
},
// 快照更新
snapshotFormat: {
printBasicPrototype: false
}
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
}
})
2. 第一个测试用例
// sum.test.ts
import { describe, it, expect } from 'vitest'
function sum(a: number, b: number) {
return a + b
}
describe('sum 函数', () => {
it('应该正确计算两个数字的和', () => {
expect(sum(1, 2)).toBe(3)
})
it('应该处理负数', () => {
expect(sum(-1, 1)).toBe(0)
})
it('应该处理浮点数', () => {
expect(sum(0.1, 0.2)).toBeCloseTo(0.3)
})
})
// 运行测试
// npm run test 或 vitest
🔍 Vitest 核心 API
3. 断言语法
import { expect, describe, it } from 'vitest'
describe('Assertions Examples', () => {
// 基础断言
it('基础断言', () => {
expect(2 + 2).toBe(4)
expect({ a: 1 }).toEqual({ a: 1 })
expect(undefined).toBeUndefined()
expect(null).toBeNull()
})
// 布尔值
it('真假值', () => {
expect(true).toBeTruthy()
expect(false).toBeFalsy()
})
// 数字
it('数字断言', () => {
expect(4).toBeGreaterThan(3)
expect(3).toBeLessThan(4)
expect(3).toBeGreaterThanOrEqual(3)
expect(3.5).toBeCloseTo(3, 1)
})
// 字符串
it('字符串断言', () => {
expect('Hello World').toContain('World')
expect('Hello').toMatch(/ell/)
})
// 数组
it('数组断言', () => {
expect([1, 2, 3]).toContain(2)
expect([1, 2, 3]).toHaveLength(3)
expect([{ id: 1 }, { id: 2 }]).toEqual(
expect.arrayContaining([{ id: 1 }])
)
})
// 异常
it('异常断言', () => {
expect(() => {
throw new Error('Test error')
}).toThrow()
expect(() => {
throw new Error('Test error')
}).toThrow('Test error')
})
// 不操作
it('not 操作', () => {
expect(2).not.toBe(3)
expect([]).not.toContain(1)
})
})
4. Setup 和 Teardown
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'
describe('Setup and Teardown', () => {
let testData: any
// 在每个测试前执行
beforeEach(() => {
testData = { id: 1, name: 'Test' }
})
// 在每个测试后执行
afterEach(() => {
testData = null
})
// 在所有测试前执行一次
beforeAll(() => {
console.log('开始运行测试套件')
})
// 在所有测试后执行一次
afterAll(() => {
console.log('测试套件结束')
})
it('应该有测试数据', () => {
expect(testData.id).toBe(1)
})
it('应该能修改数据', () => {
testData.name = 'Modified'
expect(testData.name).toBe('Modified')
})
})
5. Mock 和 Stub
import { describe, it, expect, vi } from 'vitest'
// 模拟函数
describe('Mock Functions', () => {
it('模拟简单函数', () => {
const mockFn = vi.fn()
mockFn()
mockFn('hello')
expect(mockFn).toHaveBeenCalled()
expect(mockFn).toHaveBeenCalledTimes(2)
expect(mockFn).toHaveBeenCalledWith('hello')
})
it('模拟返回值', () => {
const mockFn = vi.fn()
mockFn.mockReturnValue(42)
expect(mockFn()).toBe(42)
})
it('模拟异步函数', async () => {
const mockFn = vi.fn()
mockFn.mockResolvedValue({ id: 1 })
const result = await mockFn()
expect(result.id).toBe(1)
})
})
// 模拟模块
describe('Mock Modules', () => {
vi.mock('./api', () => ({
fetchUser: vi.fn(() => ({ id: 1, name: 'John' }))
}))
it('应该使用模拟的 API', async () => {
const { fetchUser } = await import('./api')
const user = fetchUser()
expect(user.name).toBe('John')
})
})
// 模拟第三方库
import * as httpClient from 'axios'
vi.mock('axios')
describe('HTTP Client Mock', () => {
it('应该模拟 HTTP 请求', async () => {
const mockedAxios = httpClient as any
mockedAxios.get.mockResolvedValue({ data: { id: 1 } })
const response = await httpClient.get('/api/user')
expect(response.data.id).toBe(1)
})
})
🧪 Vue 组件测试
6. Vue 组件单元测试
// Button.vue
<script setup lang="ts">
interface Props {
disabled?: boolean
variant?: 'primary' | 'secondary'
}
withDefaults(defineProps<Props>(), {
variant: 'primary'
})
const emit = defineEmits<{
click: []
}>()
</script>
<template>
<button
:disabled="disabled"
:class="{
'btn-primary': variant === 'primary',
'btn-secondary': variant === 'secondary'
}"
@click="emit('click')"
>
<slot></slot>
</button>
</template>
// Button.test.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import Button from './Button.vue'
describe('Button Component', () => {
it('应该渲染插槽内容', () => {
const wrapper = mount(Button, {
slots: {
default: 'Click Me'
}
})
expect(wrapper.text()).toContain('Click Me')
})
it('应该处理点击事件', async () => {
const wrapper = mount(Button)
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('click')).toBeTruthy()
})
it('应该在禁用时不触发事件', async () => {
const wrapper = mount(Button, {
props: {
disabled: true
}
})
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('click')).toBeFalsy()
})
it('应该应用正确的 CSS 类', () => {
const wrapper = mount(Button, {
props: {
variant: 'secondary'
}
})
expect(wrapper.find('button').classes('btn-secondary')).toBe(true)
})
it('应该支持 disabled 属性', () => {
const wrapper = mount(Button, {
props: {
disabled: true
}
})
expect(wrapper.find('button').attributes('disabled')).toBeDefined()
})
})
7. 异步组件测试
import { describe, it, expect, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import UserProfile from './UserProfile.vue'
describe('UserProfile Component', () => {
it('应该在挂载时加载用户数据', async () => {
const mockFetch = vi.fn()
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ id: 1, name: 'John' })
})
global.fetch = mockFetch
const wrapper = mount(UserProfile, {
props: { userId: 1 }
})
await flushPromises()
expect(wrapper.text()).toContain('John')
})
it('应该处理加载错误', async () => {
const mockFetch = vi.fn()
mockFetch.mockRejectedValue(new Error('Network error'))
global.fetch = mockFetch
const wrapper = mount(UserProfile, {
props: { userId: 1 }
})
await flushPromises()
expect(wrapper.text()).toContain('Error loading user')
})
it('应该在 userId 改变时重新加载数据', async () => {
const mockFetch = vi.fn()
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ id: 1, name: 'John' })
})
global.fetch = mockFetch
const wrapper = mount(UserProfile, {
props: { userId: 1 }
})
await flushPromises()
await wrapper.setProps({ userId: 2 })
await flushPromises()
expect(mockFetch).toHaveBeenCalledTimes(2)
})
})
🔌 Jest 实战
8. Jest 基础设置
# 安装 Jest 和 Vue 测试库
npm install -D jest @vue/vue3-jest babel-jest ts-jest
# 创建配置文件
npx jest --init
jest.config.js 配置
module.exports = {
preset: '@vue/cli-plugin-unit-jest/presets/typescript-and-babel',
testEnvironment: 'jsdom',
transform: {
'^.+\\.vue$': '@vue/vue3-jest',
'^.+\\.[jt]sx?$': 'babel-jest'
},
moduleFileExtensions: ['js', 'ts', 'json', 'vue'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1'
},
collectCoverageFrom: [
'src/**/*.{js,ts,vue}',
'!src/main.ts',
'!**/*.spec.ts'
],
coverageThreshold: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70
}
}
}
9. Jest 实用工具函数测试
// utils.ts
export function calculateDiscount(price: number, percentage: number): number {
if (price < 0 || percentage < 0) {
throw new Error('Invalid input')
}
return price * (1 - percentage / 100)
}
export function formatDate(date: Date): string {
return date.toLocaleDateString('en-US')
}
export function validateEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
// utils.test.ts
import { describe, it, expect } from '@jest/globals'
import { calculateDiscount, formatDate, validateEmail } from './utils'
describe('Utils Functions', () => {
describe('calculateDiscount', () => {
it('应该正确计算折扣', () => {
expect(calculateDiscount(100, 10)).toBe(90)
expect(calculateDiscount(50, 50)).toBe(25)
})
it('应该抛出错误当输入无效', () => {
expect(() => calculateDiscount(-100, 10)).toThrow()
expect(() => calculateDiscount(100, -10)).toThrow()
})
})
describe('formatDate', () => {
it('应该正确格式化日期', () => {
const date = new Date('2024-01-15')
const formatted = formatDate(date)
expect(formatted).toMatch(/\d{1,2}\/\d{1,2}\/\d{4}/)
})
})
describe('validateEmail', () => {
it('应该验证有效的邮箱', () => {
expect(validateEmail('test@example.com')).toBe(true)
expect(validateEmail('user.name+tag@example.co.uk')).toBe(true)
})
it('应该拒绝无效的邮箱', () => {
expect(validateEmail('invalid.email')).toBe(false)
expect(validateEmail('@example.com')).toBe(false)
expect(validateEmail('test@')).toBe(false)
})
})
})
📊 实战:完整应用测试
10. API 服务测试
// api.ts
import axios from 'axios'
export interface User {
id: number
name: string
email: string
}
export async function fetchUser(id: number): Promise<User> {
const response = await axios.get(`/api/users/${id}`)
return response.data
}
export async function createUser(data: Omit<User, 'id'>): Promise<User> {
const response = await axios.post('/api/users', data)
return response.data
}
export async function updateUser(id: number, data: Partial<User>): Promise<User> {
const response = await axios.put(`/api/users/${id}`, data)
return response.data
}
export async function deleteUser(id: number): Promise<void> {
await axios.delete(`/api/users/${id}`)
}
// api.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import axios from 'axios'
import { fetchUser, createUser, updateUser, deleteUser } from './api'
vi.mock('axios')
const mockedAxios = axios as any
describe('User API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('应该获取用户数据', async () => {
const mockUser = { id: 1, name: 'John', email: 'john@example.com' }
mockedAxios.get.mockResolvedValue({ data: mockUser })
const user = await fetchUser(1)
expect(user).toEqual(mockUser)
expect(mockedAxios.get).toHaveBeenCalledWith('/api/users/1')
})
it('应该创建新用户', async () => {
const newUser = { name: 'Jane', email: 'jane@example.com' }
const createdUser = { id: 2, ...newUser }
mockedAxios.post.mockResolvedValue({ data: createdUser })
const user = await createUser(newUser)
expect(user).toEqual(createdUser)
expect(mockedAxios.post).toHaveBeenCalledWith('/api/users', newUser)
})
it('应该更新用户数据', async () => {
const updated = { name: 'John Updated' }
const result = { id: 1, ...updated, email: 'john@example.com' }
mockedAxios.put.mockResolvedValue({ data: result })
const user = await updateUser(1, updated)
expect(user.name).toBe('John Updated')
expect(mockedAxios.put).toHaveBeenCalledWith('/api/users/1', updated)
})
it('应该删除用户', async () => {
mockedAxios.delete.mockResolvedValue({})
await deleteUser(1)
expect(mockedAxios.delete).toHaveBeenCalledWith('/api/users/1')
})
it('应该处理 API 错误', async () => {
mockedAxios.get.mockRejectedValue(new Error('Network error'))
await expect(fetchUser(1)).rejects.toThrow('Network error')
})
})
🐛 覆盖率和性能
11. 测试覆盖率
# Vitest 生成覆盖率报告
vitest run --coverage
# Jest 生成覆盖率报告
jest --coverage
# 输出结果示例
# ✓ src/utils.ts 92% ( 23/25 lines)
# ✓ src/api.ts 85% ( 17/20 lines)
# ✓ src/components/Button.vue 98% ( 50/51 lines)
# Overall Coverage: 91.7%
覆盖率目标
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
lines: 80, // 行覆盖率
functions: 80, // 函数覆盖率
branches: 75, // 分支覆盖率
statements: 80 // 语句覆盖率
}
}
})
12. 性能监控
import { describe, it, expect } from 'vitest'
describe('Performance Tests', () => {
it('应该在 100ms 内完成', async () => {
const start = performance.now()
// 执行操作
await heavyComputation()
const duration = performance.now() - start
expect(duration).toBeLessThan(100)
})
it('应该处理大数据集', () => {
const largeArray = Array.from({ length: 1000000 }, (_, i) => i)
expect(largeArray).toHaveLength(1000000)
expect(largeArray[999999]).toBe(999999)
})
})
🎓 最佳实践
DO ✅
// 1. 清晰的测试名称
it('应该在用户点击时发出 click 事件', () => {})
// 2. 遵循 AAA 模式(Arrange-Act-Assert)
it('test', () => {
// Arrange: 准备数据
const input = { id: 1 }
// Act: 执行操作
const result = processData(input)
// Assert: 验证结果
expect(result).toBeDefined()
})
// 3. 测试一个概念
it('应该正确处理有效邮箱', () => {
expect(validateEmail('test@example.com')).toBe(true)
})
// 4. 使用有意义的 Mock
const mockFetch = vi.fn()
mockFetch.mockResolvedValue({ status: 200 })
// 5. 定期检查覆盖率
// npm run coverage
DON'T ❌
// 1. 避免测试多个概念
it('should do everything', () => {
// ❌ 太多不相关的断言
})
// 2. 避免使用真实的 API
// ❌ 不要在测试中调用真实的 HTTP
fetch('/api/users')
// 3. 避免测试实现细节
it('should call getUser function', () => {
// ❌ 测试内部调用而不是行为
})
// 4. 避免相互依赖的测试
// 每个测试应该独立运行
// 5. 避免断言过少
it('test', () => {
const result = calculate()
// ❌ 只有一个模糊的断言
expect(result).toBeDefined()
})
📚 扩展资源
总结
单元测试的核心要素:
- 正确的框架选择:Vitest(Vite 项目)或 Jest(通用)
- 清晰的测试结构:AAA 模式(Arrange-Act-Assert)
- 合理的 Mock:隔离外部依赖
- 足够的覆盖率:80% 以上
- 实用的测试:测试行为而不是实现
- 性能监控:确保测试速度快
掌握单元测试,能够构建更加可靠、可维护的应用代码!