正在加载,请稍候…

Vitest 单元测试:从配置到高级 Mock 与覆盖率

全面的 Vitest 指南,涵盖配置、describe/it/expect 模式、模拟函数、模块模拟、快照测试、并发测试和覆盖率报告。

Vitest 单元测试:从配置到高级 Mock 与覆盖率

为什么 Vitest 已取代 Jest 成为大多数项目的首选

到 2026 年,Vitest 已成为新 JavaScript/TypeScript 项目的默认测试框架。它与你的应用程序运行在同一个 Vite 管道中,这意味着你的测试环境与开发环境完全一致。不再有因不同转译器或模块解析导致的“开发环境正常但测试环境失败”的神秘 bug。

Vitest 也很快。一个在 Jest 中需要 45 秒的 500 个测试套件,在 Vitest 中通常只需 8 秒,这得益于原生 ES 模块支持、并行执行以及避免了 CommonJS 互操作开销。

Vitest 单元测试:从配置到高级 Mock 与覆盖率 插图

初始设置

npm install -D vitest @vitest/coverage-v8 @testing-library/react jsdom
// vite.config.ts — 添加 test 块
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    include: ['**/*.{test,spec}.{ts,tsx}'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      include: ['src/**/*.{ts,tsx}'],
      exclude: ['src/**/*.d.ts', 'src/test/**'],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 70,
      },
    },
    alias: {
      '@': new URL('./src', import.meta.url).pathname,
    },
  },
})
// src/test/setup.ts
import '@testing-library/jest-dom'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'

afterEach(cleanup)
vi.stubEnv('API_BASE_URL', 'http://localhost:3000')

核心测试原语

describe, it, expect

// src/utils/formatters.test.ts
import { describe, it, expect } from 'vitest'
import { formatCurrency, truncate } from './formatters'

describe('formatCurrency', () => {
  it('formats USD with dollar sign and two decimal places', () => {
    expect(formatCurrency(1234.5, 'USD')).toBe('$1,234.50')
  })

  it('handles zero correctly', () => {
    expect(formatCurrency(0, 'USD')).toBe('$0.00')
  })

  it('handles negative values', () => {
    expect(formatCurrency(-50, 'USD')).toBe('-$50.00')
  })
})

describe('truncate', () => {
  it('returns original string when shorter than limit', () => {
    expect(truncate('hello', 10)).toBe('hello')
  })

  it('truncates and adds ellipsis when too long', () => {
    expect(truncate('hello world', 5)).toBe('hello...')
  })
})

测试异步代码

import { describe, it, expect } from 'vitest'
import { fetchUser } from './userService'

describe('userService', () => {
  it('fetches user by id', async () => {
    const user = await fetchUser('user-123')
    expect(user).toMatchObject({
      id: 'user-123',
      email: expect.stringContaining('@'),
    })
  })

  it('throws on 404', async () => {
    await expect(fetchUser('non-existent')).rejects.toThrow('User not found')
  })
})

Vitest 单元测试:从配置到高级 Mock 与覆盖率 插图

模拟函数:vi.fn()

import { describe, it, expect, vi } from 'vitest'

describe('vi.fn() basics', () => {
  it('tracks calls and arguments', () => {
    const mockFn = vi.fn()

    mockFn('first call')
    mockFn('second call', { with: 'object' })

    expect(mockFn).toHaveBeenCalledTimes(2)
    expect(mockFn).toHaveBeenCalledWith('first call')
    expect(mockFn).toHaveBeenLastCalledWith('second call', { with: 'object' })
  })

  it('returns controlled values with mockReturnValueOnce', () => {
    const mockFn = vi
      .fn()
      .mockReturnValueOnce('first')
      .mockReturnValueOnce('second')
      .mockReturnValue('default')

    expect(mockFn()).toBe('first')
    expect(mockFn()).toBe('second')
    expect(mockFn()).toBe('default')
  })

  it('mocks async implementations', async () => {
    const mockFetch = vi.fn().mockResolvedValueOnce({ data: 'success' })
    const result = await mockFetch('/api/data')
    expect(result).toEqual({ data: 'success' })
  })

  it('simulates rejections', async () => {
    const mockFetch = vi.fn().mockRejectedValueOnce(new Error('Network error'))
    await expect(mockFetch('/api/data')).rejects.toThrow('Network error')
  })
})

模块模拟:vi.mock()

// vi.mock() 会被提升到文件顶部
vi.mock('../lib/emailProvider', () => ({
  sendEmail: vi.fn().mockResolvedValue({ messageId: 'mock-id-123' }),
  validateEmail: vi.fn().mockReturnValue(true),
}))

import { sendEmail, validateEmail } from '../lib/emailProvider'
import { sendWelcomeEmail } from './emailService'
import { describe, it, expect, vi, beforeEach } from 'vitest'

describe('emailService', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  it('sends welcome email with correct template', async () => {
    await sendWelcomeEmail({ email: 'user@example.com', name: 'Alice' })

    expect(sendEmail).toHaveBeenCalledOnce()
    expect(sendEmail).toHaveBeenCalledWith({
      to: 'user@example.com',
      subject: 'Welcome, Alice!',
      html: expect.stringContaining('Welcome to our platform'),
    })
  })
})

部分模块模拟

vi.mock('../utils/date', async (importOriginal) => {
  const actual = await importOriginal<typeof import('../utils/date')>()
  return {
    ...actual,
    // 仅覆盖 getCurrentDate 以实现确定性测试
    getCurrentDate: vi.fn().mockReturnValue(new Date('2026-01-15T10:00:00Z')),
  }
})

快照测试

import { describe, it, expect } from 'vitest'
import { renderUserCard } from './UserCard'

describe('UserCard', () => {
  it('renders correctly for active user', () => {
    const output = renderUserCard({
      name: 'Alice',
      role: 'admin',
      status: 'active',
    })
    // 首次运行创建快照;后续运行与之比较
    expect(output).toMatchSnapshot()
  })

  it('renders inline snapshot for small outputs', () => {
    const badge = renderBadge('admin')
    expect(badge).toMatchInlineSnapshot(`
      "<span class="badge badge-admin">admin</span>"
    `)
  })
})

在有意更改后更新快照:vitest --update-snapshots

Vitest 单元测试:从配置到高级 Mock 与覆盖率 插图

并发测试以提高速度

import { describe, it, expect } from 'vitest'

// 在此块中并发运行所有测试
describe.concurrent('independent API tests', () => {
  it('fetches users', async () => {
    const users = await fetchUsers()
    expect(users).toHaveLength(10)
  })

  it('fetches products', async () => {
    const products = await fetchProducts()
    expect(products).toHaveLength(20)
  })

  it('fetches orders', async () => {
    const orders = await fetchOrders()
    expect(orders.items).toBeInstanceOf(Array)
  })
})

仅对不共享状态的测试使用 concurrent

测试 React 组件

// src/components/SearchBar.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { SearchBar } from './SearchBar'

describe('SearchBar', () => {
  it('calls onSearch with debounced input', async () => {
    const user = userEvent.setup()
    const onSearch = vi.fn()

    render(<SearchBar onSearch={onSearch} debounceMs={300} />)

    const input = screen.getByRole('textbox', { name: /search/i })
    await user.type(input, 'vitest guide')

    // 尚未调用——防抖中
    expect(onSearch).not.toHaveBeenCalled()

    // 防抖延迟后
    await waitFor(() => {
      expect(onSearch).toHaveBeenCalledWith('vitest guide')
    }, { timeout: 500 })
  })
})

覆盖率报告与 CI

# 运行测试并生成覆盖率
vitest run --coverage

# 输出显示每个文件的行/分支/函数覆盖率
# HTML 报告位于 coverage/index.html,显示确切未覆盖的行
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run test:coverage
      - uses: codecov/codecov-action@v4
        with:
          files: ./coverage/lcov.info

来自生产代码库的实用技巧

测试行为,而非实现。 一个因重命名私有函数而失败的测试是一种负担。测试函数的功能(输出、副作用),而不是其实现方式。

将测试文件与源文件放在一起。 UserCard.test.tsx 放在 UserCard.tsx 旁边比放在单独的 __tests__ 目录中更容易找到。

beforeEach 中重置模拟,而不是 afterEach 如果测试失败,afterEach 的清理可能不会运行。beforeEach 确保无论何种情况都能获得干净的状态。

在边界处模拟,而不是内部。 模拟 HTTP 客户端,而不是调用它的函数。这样可以测试实际的请求构建逻辑。