代码质量

单元测试实战指南:Vitest 和 Jest 完全掌握

深入学习 Vitest 和 Jest 的测试方法、最佳实践和实战技巧,构建高质量的可测试代码

17 分钟阅读
#Vitest #Jest #单元测试 #测试驱动开发

📖 文章概述

单元测试是代码质量的保障,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()
})

📚 扩展资源


总结

单元测试的核心要素:

  1. 正确的框架选择:Vitest(Vite 项目)或 Jest(通用)
  2. 清晰的测试结构:AAA 模式(Arrange-Act-Assert)
  3. 合理的 Mock:隔离外部依赖
  4. 足够的覆盖率:80% 以上
  5. 实用的测试:测试行为而不是实现
  6. 性能监控:确保测试速度快

掌握单元测试,能够构建更加可靠、可维护的应用代码!