前端单元测试最佳实践指南

HTMLPAGE 团队
18分钟 分钟阅读

系统讲解前端单元测试的核心理念与实践技巧,涵盖 Jest、Vitest 配置,组件测试策略,Mock 技术和 TDD 开发流程。

#单元测试 #Jest #Vitest #TDD #测试策略

为什么需要单元测试

单元测试是代码质量的第一道防线。它帮助我们在开发阶段发现问题,而不是在用户手中暴露 Bug。

收益说明
捕获回归修改代码时立即发现破坏性变更
文档作用测试用例本身就是代码的使用说明
设计改进难以测试的代码往往是设计有问题
重构信心有测试覆盖,重构时心里有底

测试框架选择

Vitest vs Jest

对比项VitestJest
速度更快(原生 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')
  })
})

关键说明: 优先使用 getByRolegetByText 等语义化查询,而非 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

通过以上实践,可以构建一个健壮的测试体系,提升代码质量和开发信心。