Skip to content

Vitest 4.0 深度解析:组件测试与快照测试的最佳实践

Vitest 已经成为前端测试的事实标准之一。4.0 版本在快照测试、组件测试和并行执行方面都有重要改进,让测试体验更加流畅。

快照测试的演进

快照测试从"争议性特性"变成了"实用工具",关键在于正确的使用姿势:

typescript
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/vue'
import UserCard from './UserCard.vue'

describe('UserCard', () => {
  it('matches snapshot for normal user', () => {
    const { container } = render(UserCard, {
      props: {
        user: {
          name: '张三',
          avatar: '/avatars/zhangsan.jpg',
          role: 'developer'
        }
      }
    })
    
    expect(container).toMatchSnapshot()
  })

  it('matches snapshot for admin user', () => {
    const { container } = render(UserCard, {
      props: {
        user: {
          name: '李四',
          avatar: '/avatars/lisi.jpg',
          role: 'admin'
        }
      }
    })
    
    expect(container).toMatchSnapshot()
  })
})

快照测试的最佳实践

  1. 小型快照优先:只快照关键 UI 片段,不要快照整个页面
  2. 有意义的快照名称:使用 toMatchSnapshot({ customSnapshotIdentifiers }) 添加上下文
  3. 定期审查快照:不要无脑接受所有快照更新
typescript
// 好的做法:小型、有意义的快照
it('renders loading state correctly', () => {
  const { container } = render(Button, { props: { loading: true } })
  expect(container.querySelector('.loading-spinner')).toMatchSnapshot()
})

// 不好的做法:大型、模糊的快照
it('renders correctly', () => {
  const { container } = render(ComplexPage)
  expect(container).toMatchSnapshot()  // 快照太大,难以维护
})

组件测试策略

测试层次划分

typescript
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import LoginForm from './LoginForm.vue'

describe('LoginForm', () => {
  // 第一层:纯渲染测试
  it('renders all form fields', () => {
    const wrapper = mount(LoginForm)
    
    expect(wrapper.find('input[name="email"]').exists()).toBe(true)
    expect(wrapper.find('input[name="password"]').exists()).toBe(true)
    expect(wrapper.find('button[type="submit"]').exists()).toBe(true)
  })

  // 第二层:交互测试
  it('emits submit event with form data', async () => {
    const wrapper = mount(LoginForm)
    
    await wrapper.find('input[name="email"]').setValue('test@example.com')
    await wrapper.find('input[name="password"]').setValue('password123')
    await wrapper.find('form').trigger('submit')
    
    expect(wrapper.emitted('submit')).toHaveLength(1)
    expect(wrapper.emitted('submit')[0]).toEqual([{
      email: 'test@example.com',
      password: 'password123'
    }])
  })

  // 第三层:验证测试
  it('shows validation error for invalid email', async () => {
    const wrapper = mount(LoginForm)
    
    await wrapper.find('input[name="email"]').setValue('invalid-email')
    await wrapper.find('form').trigger('submit')
    
    expect(wrapper.find('.error-message').text()).toContain('邮箱格式不正确')
  })
})

Mock 策略

typescript
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import UserProfile from './UserProfile.vue'
import { useUser } from '@/composables/useUser'

vi.mock('@/composables/useUser')

describe('UserProfile', () => {
  const mockUser = {
    id: '1',
    name: '张三',
    email: 'zhangsan@example.com',
    avatar: '/avatars/zhangsan.jpg'
  }

  beforeEach(() => {
    vi.mocked(useUser).mockReturnValue({
      user: ref(mockUser),
      loading: ref(false),
      error: ref(null),
      updateUser: vi.fn()
    })
  })

  it('displays user information', () => {
    const wrapper = mount(UserProfile)
    
    expect(wrapper.find('.user-name').text()).toBe('张三')
    expect(wrapper.find('.user-email').text()).toBe('zhangsan@example.com')
  })

  it('calls updateUser when form is submitted', async () => {
    const wrapper = mount(UserProfile)
    const { updateUser } = vi.mocked(useUser())
    
    await wrapper.find('input[name="name"]').setValue('李四')
    await wrapper.find('form').trigger('submit')
    
    expect(updateUser).toHaveBeenCalledWith({ name: '李四' })
  })
})

并行测试优化

Vitest 4.0 的并行测试性能得到了显著提升:

typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    // 并行测试配置
    threads: true,
    maxThreads: 4,
    minThreads: 2,
    
    // 测试文件隔离
    isolate: true,
    
    // 测试超时
    testTimeout: 10000,
    hookTimeout: 10000,
    
    // 重试机制
    retry: 2
  }
})

并行测试的注意事项

  1. 避免共享状态:每个测试应该独立,不依赖其他测试的状态
  2. 使用 beforeEach 清理:在每个测试前重置状态
  3. 合理控制并行度:过多的并行可能导致资源竞争
typescript
describe('Database Tests', () => {
  let db

  beforeEach(async () => {
    // 每个测试前创建新的数据库连接
    db = await createTestDatabase()
  })

  afterEach(async () => {
    // 每个测试后清理
    await db.cleanup()
  })

  it('creates user', async () => {
    const user = await db.createUser({ name: '张三' })
    expect(user.id).toBeDefined()
  })

  it('finds user by id', async () => {
    const created = await db.createUser({ name: '李四' })
    const found = await db.findUser(created.id)
    expect(found.name).toBe('李四')
  })
})

快照测试与组件测试的结合

typescript
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/vue'
import DataTable from './DataTable.vue'

describe('DataTable', () => {
  const columns = [
    { key: 'name', title: '姓名' },
    { key: 'age', title: '年龄' },
    { key: 'email', title: '邮箱' }
  ]

  const data = [
    { id: 1, name: '张三', age: 25, email: 'zhangsan@example.com' },
    { id: 2, name: '李四', age: 30, email: 'lisi@example.com' }
  ]

  it('renders table with correct structure', () => {
    const { container } = render(DataTable, {
      props: { columns, data }
    })
    
    // 结构验证
    expect(container.querySelector('table')).toBeTruthy()
    expect(container.querySelectorAll('th')).toHaveLength(3)
    expect(container.querySelectorAll('tbody tr')).toHaveLength(2)
    
    // 快照验证视觉样式
    expect(container.querySelector('thead')).toMatchSnapshot()
  })

  it('handles empty data gracefully', () => {
    const { container } = render(DataTable, {
      props: { columns, data: [] }
    })
    
    expect(container.querySelector('.empty-state')).toBeTruthy()
    expect(container.querySelector('.empty-state').textContent).toContain('暂无数据')
  })
})

测试覆盖率策略

typescript
// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'src/types/',
        '*.config.*'
      ],
      thresholds: {
        statements: 80,
        branches: 75,
        functions: 80,
        lines: 80
      }
    }
  }
})

覆盖率的最佳实践

  1. 关注关键路径:核心业务逻辑需要高覆盖率
  2. 避免为了覆盖率而测试:测试应该验证行为,而不是追求数字
  3. 使用覆盖率指导补充测试:低覆盖率的地方可能是测试遗漏
typescript
// 好的做法:针对关键业务逻辑
describe('PaymentService', () => {
  it('calculates total with tax correctly', () => {
    const service = new PaymentService()
    const total = service.calculateTotal(100, 0.1)
    expect(total).toBe(110)
  })

  it('handles discount correctly', () => {
    const service = new PaymentService()
    const total = service.calculateTotal(100, 0.1, 0.2)
    expect(total).toBe(88)  // 100 * 1.1 * 0.8
  })
})

// 不好的做法:为了覆盖率而测试
it('has calculateTotal method', () => {
  const service = new PaymentService()
  expect(typeof service.calculateTotal).toBe('function')
})

小结

Vitest 4.0 让前端测试变得更加快速和可靠。快照测试的关键是"小而精",组件测试的关键是"层次分明",并行测试的关键是"隔离状态"。在实际项目中,合理的测试策略比追求 100% 覆盖率更重要——测试是为了验证行为,而不是为了满足数字指标。

MIT Licensed