为什么需要单元测试
单元测试是代码质量的第一道防线。它帮助我们在开发阶段发现问题,而不是在用户手中暴露 Bug。
| 收益 | 说明 |
|---|---|
| 捕获回归 | 修改代码时立即发现破坏性变更 |
| 文档作用 | 测试用例本身就是代码的使用说明 |
| 设计改进 | 难以测试的代码往往是设计有问题 |
| 重构信心 | 有测试覆盖,重构时心里有底 |
测试框架选择
Vitest vs Jest
| 对比项 | Vitest | Jest |
|---|---|---|
| 速度 | 更快(原生 ESM) | 较慢 |
| 配置 | 可复用 Vite 配置 | 独立配置 |
| 生态 | 较新但兼容 Jest API | 成熟稳定 |
| 适用场景 | Vite 项目首选 | 通用项目 |
对于 Vue/Nuxt 项目,推荐使用 Vitest:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
coverage: {
provider: 'v8',
reporter: ['text', 'html']
}
}
})
测试的基本结构
AAA 模式
每个测试用例应遵循 Arrange-Act-Assert 模式:
describe('购物车计算', () => {
it('应正确计算商品总价', () => {
// Arrange - 准备测试数据
const items = [
{ name: '商品A', price: 100, quantity: 2 },
{ name: '商品B', price: 50, quantity: 1 }
]
// Act - 执行被测试的操作
const total = calculateTotal(items)
// Assert - 验证结果
expect(total).toBe(250)
})
})
描述性测试命名
测试名称应该清晰描述"做什么"和"预期结果":
// ❌ 不好的命名
it('test add', () => {})
// ✅ 好的命名
it('两个正数相加应返回正确的和', () => {})
it('当输入为空时应抛出错误', () => {})
组件测试策略
使用 Testing Library
Vue Testing Library 鼓励从用户角度测试组件:
import { render, screen, fireEvent } from '@testing-library/vue'
import Counter from './Counter.vue'
describe('Counter 组件', () => {
it('点击按钮后数值应增加', async () => {
// 渲染组件
render(Counter, {
props: { initialCount: 0 }
})
// 查找元素(模拟用户行为)
const button = screen.getByRole('button', { name: '增加' })
const display = screen.getByTestId('count-display')
// 验证初始状态
expect(display.textContent).toBe('0')
// 触发交互
await fireEvent.click(button)
// 验证结果
expect(display.textContent).toBe('1')
})
})
关键说明: 优先使用 getByRole、getByText 等语义化查询,而非 querySelector。这样测试更贴近用户的实际使用方式。
异步组件测试
import { render, screen, waitFor } from '@testing-library/vue'
import UserProfile from './UserProfile.vue'
it('应正确显示用户信息', async () => {
render(UserProfile, {
props: { userId: '123' }
})
// 等待异步数据加载
await waitFor(() => {
expect(screen.getByText('张三')).toBeInTheDocument()
})
// 验证其他信息
expect(screen.getByText('用户ID: 123')).toBeInTheDocument()
})
Mock 技术详解
函数 Mock
import { vi } from 'vitest'
import { sendAnalytics } from './analytics'
import { trackButtonClick } from './tracker'
// Mock 整个模块
vi.mock('./analytics', () => ({
sendAnalytics: vi.fn()
}))
describe('点击追踪', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('点击按钮时应发送分析事件', () => {
trackButtonClick('submit-btn')
expect(sendAnalytics).toHaveBeenCalledWith({
event: 'button_click',
buttonId: 'submit-btn'
})
expect(sendAnalytics).toHaveBeenCalledTimes(1)
})
})
API Mock
import { vi } from 'vitest'
// Mock fetch
global.fetch = vi.fn()
function mockFetchResponse(data: any, status = 200) {
;(fetch as any).mockResolvedValueOnce({
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(data)
})
}
describe('用户服务', () => {
it('应正确获取用户列表', async () => {
mockFetchResponse([{ id: 1, name: '张三' }])
const users = await fetchUsers()
expect(users).toHaveLength(1)
expect(users[0].name).toBe('张三')
})
it('API 错误时应抛出异常', async () => {
mockFetchResponse({ error: 'Not found' }, 404)
await expect(fetchUsers()).rejects.toThrow('获取用户失败')
})
})
时间 Mock
import { vi, beforeEach, afterEach } from 'vitest'
describe('防抖函数', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('应在延迟后执行', () => {
const callback = vi.fn()
const debounced = debounce(callback, 1000)
debounced()
expect(callback).not.toHaveBeenCalled()
vi.advanceTimersByTime(500)
expect(callback).not.toHaveBeenCalled()
vi.advanceTimersByTime(500)
expect(callback).toHaveBeenCalledTimes(1)
})
})
测试覆盖率策略
覆盖率指标
| 指标 | 说明 | 建议目标 |
|---|---|---|
| 行覆盖 | 代码行被执行的比例 | 80%+ |
| 分支覆盖 | if/else 分支被覆盖比例 | 70%+ |
| 函数覆盖 | 函数被调用的比例 | 90%+ |
配置覆盖率报告
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
exclude: [
'node_modules/',
'test/',
'**/*.d.ts',
'**/*.config.*'
],
thresholds: {
lines: 80,
branches: 70,
functions: 90
}
}
}
})
测试最佳实践
1. 每个测试应独立
// ❌ 测试之间有依赖
let user: User
it('创建用户', () => {
user = createUser({ name: '张三' })
expect(user.id).toBeDefined()
})
it('更新用户', () => {
updateUser(user.id, { name: '李四' }) // 依赖上一个测试
})
// ✅ 每个测试独立
it('创建用户', () => {
const user = createUser({ name: '张三' })
expect(user.id).toBeDefined()
})
it('更新用户', () => {
const user = createUser({ name: '张三' })
updateUser(user.id, { name: '李四' })
expect(user.name).toBe('李四')
})
2. 测试行为而非实现
// ❌ 测试内部实现
it('应调用 setState', () => {
const spy = vi.spyOn(component, 'setState')
component.increment()
expect(spy).toHaveBeenCalledWith({ count: 1 })
})
// ✅ 测试行为结果
it('增加后显示的数值应+1', () => {
component.increment()
expect(component.displayValue).toBe(1)
})
3. 使用测试数据工厂
// factories/user.ts
export function createTestUser(overrides: Partial<User> = {}): User {
return {
id: 'test-id',
name: '测试用户',
email: 'test@example.com',
role: 'user',
createdAt: new Date('2024-01-01'),
...overrides
}
}
// 使用
it('管理员应有删除权限', () => {
const admin = createTestUser({ role: 'admin' })
expect(canDelete(admin)).toBe(true)
})
持续集成中的测试
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run test:coverage
- uses: codecov/codecov-action@v3
通过以上实践,可以构建一个健壮的测试体系,提升代码质量和开发信心。


