Skip to content

测试

测试是确保 Vue.js 应用质量和稳定性的重要环节。本指南涵盖了单元测试、组件测试、集成测试和端到端测试的最佳实践。

测试环境搭建

Vitest 配置

javascript
// vitest.config.js
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

export default defineConfig({
  plugins: [vue()],
  test: {
    // 测试环境
    environment: 'jsdom',
    
    // 全局配置
    globals: true,
    
    // 设置文件
    setupFiles: ['./tests/setup.js'],
    
    // 覆盖率配置
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'tests/',
        '**/*.d.ts',
        '**/*.config.js'
      ]
    },
    
    // 测试文件匹配模式
    include: [
      'tests/**/*.{test,spec}.{js,ts}',
      'src/**/__tests__/*.{test,spec}.{js,ts}'
    ]
  },
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src')
    }
  }
})

测试设置文件

javascript
// tests/setup.js
import { vi } from 'vitest'
import { config } from '@vue/test-utils'

// 全局组件注册
config.global.components = {
  // 注册全局组件
}

// 全局插件
config.global.plugins = [
  // 注册插件
]

// Mock 全局对象
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: vi.fn().mockImplementation(query => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: vi.fn(),
    removeListener: vi.fn(),
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn()
  }))
})

// Mock IntersectionObserver
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
  observe: vi.fn(),
  unobserve: vi.fn(),
  disconnect: vi.fn()
}))

// Mock ResizeObserver
global.ResizeObserver = vi.fn().mockImplementation(() => ({
  observe: vi.fn(),
  unobserve: vi.fn(),
  disconnect: vi.fn()
}))

单元测试

工具函数测试

javascript
// src/utils/validators.js
export function validateEmail(email) {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
  return emailRegex.test(email)
}

export function validatePassword(password) {
  return password.length >= 8 && /[A-Z]/.test(password) && /[0-9]/.test(password)
}

export function formatCurrency(amount, currency = 'CNY') {
  return new Intl.NumberFormat('zh-CN', {
    style: 'currency',
    currency
  }).format(amount)
}

export function debounce(func, wait) {
  let timeout
  return function executedFunction(...args) {
    const later = () => {
      clearTimeout(timeout)
      func(...args)
    }
    clearTimeout(timeout)
    timeout = setTimeout(later, wait)
  }
}
javascript
// tests/utils/validators.test.js
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { validateEmail, validatePassword, formatCurrency, debounce } from '@/utils/validators'

describe('validators', () => {
  describe('validateEmail', () => {
    it('should return true for valid email', () => {
      expect(validateEmail('test@example.com')).toBe(true)
      expect(validateEmail('user.name+tag@domain.co.uk')).toBe(true)
    })
    
    it('should return false for invalid email', () => {
      expect(validateEmail('invalid-email')).toBe(false)
      expect(validateEmail('test@')).toBe(false)
      expect(validateEmail('@example.com')).toBe(false)
      expect(validateEmail('')).toBe(false)
    })
  })
  
  describe('validatePassword', () => {
    it('should return true for valid password', () => {
      expect(validatePassword('Password123')).toBe(true)
      expect(validatePassword('MySecure1')).toBe(true)
    })
    
    it('should return false for invalid password', () => {
      expect(validatePassword('short')).toBe(false) // 太短
      expect(validatePassword('nouppercase1')).toBe(false) // 没有大写字母
      expect(validatePassword('NoNumbers')).toBe(false) // 没有数字
      expect(validatePassword('')).toBe(false) // 空字符串
    })
  })
  
  describe('formatCurrency', () => {
    it('should format currency correctly', () => {
      expect(formatCurrency(1234.56)).toBe('¥1,234.56')
      expect(formatCurrency(0)).toBe('¥0.00')
      expect(formatCurrency(1000000)).toBe('¥1,000,000.00')
    })
    
    it('should support different currencies', () => {
      expect(formatCurrency(100, 'USD')).toContain('100')
      expect(formatCurrency(100, 'EUR')).toContain('100')
    })
  })
  
  describe('debounce', () => {
    beforeEach(() => {
      vi.useFakeTimers()
    })
    
    afterEach(() => {
      vi.useRealTimers()
    })
    
    it('should delay function execution', () => {
      const mockFn = vi.fn()
      const debouncedFn = debounce(mockFn, 100)
      
      debouncedFn()
      expect(mockFn).not.toHaveBeenCalled()
      
      vi.advanceTimersByTime(100)
      expect(mockFn).toHaveBeenCalledTimes(1)
    })
    
    it('should cancel previous calls', () => {
      const mockFn = vi.fn()
      const debouncedFn = debounce(mockFn, 100)
      
      debouncedFn()
      debouncedFn()
      debouncedFn()
      
      vi.advanceTimersByTime(100)
      expect(mockFn).toHaveBeenCalledTimes(1)
    })
  })
})

Composables 测试

javascript
// src/composables/useCounter.js
import { ref, computed } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  
  const doubleCount = computed(() => count.value * 2)
  
  function increment() {
    count.value++
  }
  
  function decrement() {
    count.value--
  }
  
  function reset() {
    count.value = initialValue
  }
  
  return {
    count,
    doubleCount,
    increment,
    decrement,
    reset
  }
}
javascript
// tests/composables/useCounter.test.js
import { describe, it, expect } from 'vitest'
import { useCounter } from '@/composables/useCounter'

describe('useCounter', () => {
  it('should initialize with default value', () => {
    const { count, doubleCount } = useCounter()
    
    expect(count.value).toBe(0)
    expect(doubleCount.value).toBe(0)
  })
  
  it('should initialize with custom value', () => {
    const { count, doubleCount } = useCounter(10)
    
    expect(count.value).toBe(10)
    expect(doubleCount.value).toBe(20)
  })
  
  it('should increment count', () => {
    const { count, increment } = useCounter(5)
    
    increment()
    expect(count.value).toBe(6)
  })
  
  it('should decrement count', () => {
    const { count, decrement } = useCounter(5)
    
    decrement()
    expect(count.value).toBe(4)
  })
  
  it('should reset count to initial value', () => {
    const { count, increment, reset } = useCounter(10)
    
    increment()
    increment()
    expect(count.value).toBe(12)
    
    reset()
    expect(count.value).toBe(10)
  })
  
  it('should update doubleCount when count changes', () => {
    const { count, doubleCount, increment } = useCounter(0)
    
    expect(doubleCount.value).toBe(0)
    
    increment()
    expect(doubleCount.value).toBe(2)
    
    increment()
    expect(doubleCount.value).toBe(4)
  })
})

API 服务测试

javascript
// src/services/userService.js
import axios from 'axios'

const API_BASE_URL = 'https://api.example.com'

export class UserService {
  async getUser(id) {
    try {
      const response = await axios.get(`${API_BASE_URL}/users/${id}`)
      return response.data
    } catch (error) {
      throw new Error(`Failed to fetch user: ${error.message}`)
    }
  }
  
  async createUser(userData) {
    try {
      const response = await axios.post(`${API_BASE_URL}/users`, userData)
      return response.data
    } catch (error) {
      throw new Error(`Failed to create user: ${error.message}`)
    }
  }
  
  async updateUser(id, userData) {
    try {
      const response = await axios.put(`${API_BASE_URL}/users/${id}`, userData)
      return response.data
    } catch (error) {
      throw new Error(`Failed to update user: ${error.message}`)
    }
  }
  
  async deleteUser(id) {
    try {
      await axios.delete(`${API_BASE_URL}/users/${id}`)
      return true
    } catch (error) {
      throw new Error(`Failed to delete user: ${error.message}`)
    }
  }
}

export const userService = new UserService()
javascript
// tests/services/userService.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest'
import axios from 'axios'
import { UserService } from '@/services/userService'

// Mock axios
vi.mock('axios')
const mockedAxios = vi.mocked(axios)

describe('UserService', () => {
  let userService
  
  beforeEach(() => {
    userService = new UserService()
    vi.clearAllMocks()
  })
  
  describe('getUser', () => {
    it('should fetch user successfully', async () => {
      const mockUser = { id: 1, name: 'John Doe', email: 'john@example.com' }
      mockedAxios.get.mockResolvedValue({ data: mockUser })
      
      const result = await userService.getUser(1)
      
      expect(mockedAxios.get).toHaveBeenCalledWith('https://api.example.com/users/1')
      expect(result).toEqual(mockUser)
    })
    
    it('should throw error when request fails', async () => {
      mockedAxios.get.mockRejectedValue(new Error('Network error'))
      
      await expect(userService.getUser(1)).rejects.toThrow('Failed to fetch user: Network error')
    })
  })
  
  describe('createUser', () => {
    it('should create user successfully', async () => {
      const userData = { name: 'Jane Doe', email: 'jane@example.com' }
      const mockResponse = { id: 2, ...userData }
      mockedAxios.post.mockResolvedValue({ data: mockResponse })
      
      const result = await userService.createUser(userData)
      
      expect(mockedAxios.post).toHaveBeenCalledWith('https://api.example.com/users', userData)
      expect(result).toEqual(mockResponse)
    })
    
    it('should throw error when creation fails', async () => {
      const userData = { name: 'Jane Doe', email: 'jane@example.com' }
      mockedAxios.post.mockRejectedValue(new Error('Validation error'))
      
      await expect(userService.createUser(userData)).rejects.toThrow('Failed to create user: Validation error')
    })
  })
  
  describe('updateUser', () => {
    it('should update user successfully', async () => {
      const userData = { name: 'John Smith' }
      const mockResponse = { id: 1, name: 'John Smith', email: 'john@example.com' }
      mockedAxios.put.mockResolvedValue({ data: mockResponse })
      
      const result = await userService.updateUser(1, userData)
      
      expect(mockedAxios.put).toHaveBeenCalledWith('https://api.example.com/users/1', userData)
      expect(result).toEqual(mockResponse)
    })
  })
  
  describe('deleteUser', () => {
    it('should delete user successfully', async () => {
      mockedAxios.delete.mockResolvedValue({})
      
      const result = await userService.deleteUser(1)
      
      expect(mockedAxios.delete).toHaveBeenCalledWith('https://api.example.com/users/1')
      expect(result).toBe(true)
    })
  })
})

组件测试

基础组件测试

vue
<!-- src/components/BaseButton.vue -->
<template>
  <button
    :class="buttonClasses"
    :disabled="disabled || loading"
    @click="handleClick"
  >
    <span v-if="loading" class="loading-spinner"></span>
    <slot v-else></slot>
  </button>
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({
  variant: {
    type: String,
    default: 'primary',
    validator: (value) => ['primary', 'secondary', 'danger'].includes(value)
  },
  size: {
    type: String,
    default: 'medium',
    validator: (value) => ['small', 'medium', 'large'].includes(value)
  },
  disabled: {
    type: Boolean,
    default: false
  },
  loading: {
    type: Boolean,
    default: false
  }
})

const emit = defineEmits(['click'])

const buttonClasses = computed(() => {
  return [
    'base-button',
    `base-button--${props.variant}`,
    `base-button--${props.size}`,
    {
      'base-button--disabled': props.disabled,
      'base-button--loading': props.loading
    }
  ]
})

function handleClick(event) {
  if (!props.disabled && !props.loading) {
    emit('click', event)
  }
}
</script>

<style scoped>
.base-button {
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-weight: 500;
  transition: all 0.2s;
}

.base-button--primary {
  background-color: #007bff;
  color: white;
}

.base-button--secondary {
  background-color: #6c757d;
  color: white;
}

.base-button--danger {
  background-color: #dc3545;
  color: white;
}

.base-button--small {
  padding: 4px 8px;
  font-size: 12px;
}

.base-button--medium {
  padding: 8px 16px;
  font-size: 14px;
}

.base-button--large {
  padding: 12px 24px;
  font-size: 16px;
}

.base-button--disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.base-button--loading {
  cursor: wait;
}

.loading-spinner {
  display: inline-block;
  width: 16px;
  height: 16px;
  border: 2px solid transparent;
  border-top: 2px solid currentColor;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}
</style>
javascript
// tests/components/BaseButton.test.js
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import BaseButton from '@/components/BaseButton.vue'

describe('BaseButton', () => {
  it('should render with default props', () => {
    const wrapper = mount(BaseButton, {
      slots: {
        default: 'Click me'
      }
    })
    
    expect(wrapper.text()).toBe('Click me')
    expect(wrapper.classes()).toContain('base-button')
    expect(wrapper.classes()).toContain('base-button--primary')
    expect(wrapper.classes()).toContain('base-button--medium')
  })
  
  it('should apply variant classes correctly', () => {
    const wrapper = mount(BaseButton, {
      props: { variant: 'danger' },
      slots: { default: 'Delete' }
    })
    
    expect(wrapper.classes()).toContain('base-button--danger')
  })
  
  it('should apply size classes correctly', () => {
    const wrapper = mount(BaseButton, {
      props: { size: 'large' },
      slots: { default: 'Large Button' }
    })
    
    expect(wrapper.classes()).toContain('base-button--large')
  })
  
  it('should emit click event when clicked', async () => {
    const wrapper = mount(BaseButton, {
      slots: { default: 'Click me' }
    })
    
    await wrapper.trigger('click')
    
    expect(wrapper.emitted('click')).toHaveLength(1)
    expect(wrapper.emitted('click')[0][0]).toBeInstanceOf(Event)
  })
  
  it('should not emit click event when disabled', async () => {
    const wrapper = mount(BaseButton, {
      props: { disabled: true },
      slots: { default: 'Disabled' }
    })
    
    await wrapper.trigger('click')
    
    expect(wrapper.emitted('click')).toBeUndefined()
    expect(wrapper.classes()).toContain('base-button--disabled')
  })
  
  it('should not emit click event when loading', async () => {
    const wrapper = mount(BaseButton, {
      props: { loading: true },
      slots: { default: 'Loading' }
    })
    
    await wrapper.trigger('click')
    
    expect(wrapper.emitted('click')).toBeUndefined()
    expect(wrapper.classes()).toContain('base-button--loading')
  })
  
  it('should show loading spinner when loading', () => {
    const wrapper = mount(BaseButton, {
      props: { loading: true },
      slots: { default: 'Loading' }
    })
    
    expect(wrapper.find('.loading-spinner').exists()).toBe(true)
    expect(wrapper.text()).not.toBe('Loading') // slot content should be hidden
  })
  
  it('should validate variant prop', () => {
    const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
    
    mount(BaseButton, {
      props: { variant: 'invalid' },
      slots: { default: 'Button' }
    })
    
    expect(consoleWarn).toHaveBeenCalled()
    consoleWarn.mockRestore()
  })
})

复杂组件测试

vue
<!-- src/components/UserForm.vue -->
<template>
  <form @submit.prevent="handleSubmit" class="user-form">
    <div class="form-group">
      <label for="name">姓名</label>
      <input
        id="name"
        v-model="form.name"
        type="text"
        :class="{ 'error': errors.name }"
        @blur="validateField('name')"
      >
      <span v-if="errors.name" class="error-message">{{ errors.name }}</span>
    </div>
    
    <div class="form-group">
      <label for="email">邮箱</label>
      <input
        id="email"
        v-model="form.email"
        type="email"
        :class="{ 'error': errors.email }"
        @blur="validateField('email')"
      >
      <span v-if="errors.email" class="error-message">{{ errors.email }}</span>
    </div>
    
    <div class="form-group">
      <label for="age">年龄</label>
      <input
        id="age"
        v-model.number="form.age"
        type="number"
        :class="{ 'error': errors.age }"
        @blur="validateField('age')"
      >
      <span v-if="errors.age" class="error-message">{{ errors.age }}</span>
    </div>
    
    <div class="form-actions">
      <BaseButton type="button" variant="secondary" @click="handleCancel">
        取消
      </BaseButton>
      <BaseButton type="submit" :disabled="!isFormValid" :loading="loading">
        {{ isEditing ? '更新' : '创建' }}
      </BaseButton>
    </div>
  </form>
</template>

<script setup>
import { ref, computed, watch } from 'vue'
import BaseButton from './BaseButton.vue'
import { validateEmail } from '@/utils/validators'

const props = defineProps({
  user: {
    type: Object,
    default: () => ({})
  },
  loading: {
    type: Boolean,
    default: false
  }
})

const emit = defineEmits(['submit', 'cancel'])

const form = ref({
  name: props.user.name || '',
  email: props.user.email || '',
  age: props.user.age || null
})

const errors = ref({})

const isEditing = computed(() => !!props.user.id)

const isFormValid = computed(() => {
  return form.value.name &&
         form.value.email &&
         form.value.age &&
         Object.keys(errors.value).length === 0
})

// 监听 user prop 变化
watch(
  () => props.user,
  (newUser) => {
    form.value = {
      name: newUser.name || '',
      email: newUser.email || '',
      age: newUser.age || null
    }
    errors.value = {}
  },
  { deep: true }
)

function validateField(field) {
  const value = form.value[field]
  
  switch (field) {
    case 'name':
      if (!value || value.trim().length < 2) {
        errors.value.name = '姓名至少需要2个字符'
      } else {
        delete errors.value.name
      }
      break
      
    case 'email':
      if (!value) {
        errors.value.email = '邮箱不能为空'
      } else if (!validateEmail(value)) {
        errors.value.email = '请输入有效的邮箱地址'
      } else {
        delete errors.value.email
      }
      break
      
    case 'age':
      if (!value) {
        errors.value.age = '年龄不能为空'
      } else if (value < 1 || value > 120) {
        errors.value.age = '年龄必须在1-120之间'
      } else {
        delete errors.value.age
      }
      break
  }
}

function validateForm() {
  validateField('name')
  validateField('email')
  validateField('age')
  return Object.keys(errors.value).length === 0
}

function handleSubmit() {
  if (validateForm()) {
    emit('submit', { ...form.value })
  }
}

function handleCancel() {
  emit('cancel')
}
</script>
javascript
// tests/components/UserForm.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import UserForm from '@/components/UserForm.vue'
import BaseButton from '@/components/BaseButton.vue'

// Mock validators
vi.mock('@/utils/validators', () => ({
  validateEmail: vi.fn((email) => email.includes('@'))
}))

describe('UserForm', () => {
  let wrapper
  
  beforeEach(() => {
    wrapper = mount(UserForm, {
      global: {
        components: {
          BaseButton
        }
      }
    })
  })
  
  it('should render form fields', () => {
    expect(wrapper.find('#name').exists()).toBe(true)
    expect(wrapper.find('#email').exists()).toBe(true)
    expect(wrapper.find('#age').exists()).toBe(true)
  })
  
  it('should show "创建" button for new user', () => {
    expect(wrapper.text()).toContain('创建')
  })
  
  it('should show "更新" button for existing user', async () => {
    await wrapper.setProps({
      user: { id: 1, name: 'John', email: 'john@example.com', age: 30 }
    })
    
    expect(wrapper.text()).toContain('更新')
  })
  
  it('should populate form with user data', async () => {
    const user = { id: 1, name: 'John Doe', email: 'john@example.com', age: 30 }
    await wrapper.setProps({ user })
    
    expect(wrapper.find('#name').element.value).toBe('John Doe')
    expect(wrapper.find('#email').element.value).toBe('john@example.com')
    expect(wrapper.find('#age').element.value).toBe('30')
  })
  
  it('should validate name field', async () => {
    const nameInput = wrapper.find('#name')
    
    // 测试空值
    await nameInput.setValue('')
    await nameInput.trigger('blur')
    expect(wrapper.text()).toContain('姓名至少需要2个字符')
    
    // 测试短名称
    await nameInput.setValue('A')
    await nameInput.trigger('blur')
    expect(wrapper.text()).toContain('姓名至少需要2个字符')
    
    // 测试有效名称
    await nameInput.setValue('John Doe')
    await nameInput.trigger('blur')
    expect(wrapper.text()).not.toContain('姓名至少需要2个字符')
  })
  
  it('should validate email field', async () => {
    const emailInput = wrapper.find('#email')
    
    // 测试空值
    await emailInput.setValue('')
    await emailInput.trigger('blur')
    expect(wrapper.text()).toContain('邮箱不能为空')
    
    // 测试无效邮箱
    await emailInput.setValue('invalid-email')
    await emailInput.trigger('blur')
    expect(wrapper.text()).toContain('请输入有效的邮箱地址')
    
    // 测试有效邮箱
    await emailInput.setValue('john@example.com')
    await emailInput.trigger('blur')
    expect(wrapper.text()).not.toContain('请输入有效的邮箱地址')
  })
  
  it('should validate age field', async () => {
    const ageInput = wrapper.find('#age')
    
    // 测试空值
    await ageInput.setValue('')
    await ageInput.trigger('blur')
    expect(wrapper.text()).toContain('年龄不能为空')
    
    // 测试无效年龄
    await ageInput.setValue('0')
    await ageInput.trigger('blur')
    expect(wrapper.text()).toContain('年龄必须在1-120之间')
    
    await ageInput.setValue('150')
    await ageInput.trigger('blur')
    expect(wrapper.text()).toContain('年龄必须在1-120之间')
    
    // 测试有效年龄
    await ageInput.setValue('30')
    await ageInput.trigger('blur')
    expect(wrapper.text()).not.toContain('年龄必须在1-120之间')
  })
  
  it('should disable submit button when form is invalid', async () => {
    const submitButton = wrapper.findAllComponents(BaseButton).find(btn => 
      btn.text().includes('创建')
    )
    
    expect(submitButton.props('disabled')).toBe(true)
    
    // 填写有效数据
    await wrapper.find('#name').setValue('John Doe')
    await wrapper.find('#email').setValue('john@example.com')
    await wrapper.find('#age').setValue('30')
    
    expect(submitButton.props('disabled')).toBe(false)
  })
  
  it('should emit submit event with form data', async () => {
    // 填写表单
    await wrapper.find('#name').setValue('John Doe')
    await wrapper.find('#email').setValue('john@example.com')
    await wrapper.find('#age').setValue('30')
    
    // 提交表单
    await wrapper.find('form').trigger('submit')
    
    expect(wrapper.emitted('submit')).toHaveLength(1)
    expect(wrapper.emitted('submit')[0][0]).toEqual({
      name: 'John Doe',
      email: 'john@example.com',
      age: 30
    })
  })
  
  it('should emit cancel event when cancel button is clicked', async () => {
    const cancelButton = wrapper.findAllComponents(BaseButton).find(btn => 
      btn.text().includes('取消')
    )
    
    await cancelButton.trigger('click')
    
    expect(wrapper.emitted('cancel')).toHaveLength(1)
  })
  
  it('should show loading state', async () => {
    await wrapper.setProps({ loading: true })
    
    const submitButton = wrapper.findAllComponents(BaseButton).find(btn => 
      btn.text().includes('创建')
    )
    
    expect(submitButton.props('loading')).toBe(true)
  })
})

集成测试

路由测试

javascript
// tests/integration/router.test.js
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import App from '@/App.vue'
import Home from '@/views/Home.vue'
import About from '@/views/About.vue'
import NotFound from '@/views/NotFound.vue'

const routes = [
  { path: '/', name: 'Home', component: Home },
  { path: '/about', name: 'About', component: About },
  { path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound }
]

describe('Router Integration', () => {
  let router
  let wrapper
  
  beforeEach(async () => {
    router = createRouter({
      history: createWebHistory(),
      routes
    })
    
    wrapper = mount(App, {
      global: {
        plugins: [router]
      }
    })
    
    await router.isReady()
  })
  
  it('should navigate to home page', async () => {
    await router.push('/')
    await wrapper.vm.$nextTick()
    
    expect(wrapper.findComponent(Home).exists()).toBe(true)
  })
  
  it('should navigate to about page', async () => {
    await router.push('/about')
    await wrapper.vm.$nextTick()
    
    expect(wrapper.findComponent(About).exists()).toBe(true)
  })
  
  it('should show 404 page for invalid routes', async () => {
    await router.push('/invalid-route')
    await wrapper.vm.$nextTick()
    
    expect(wrapper.findComponent(NotFound).exists()).toBe(true)
  })
  
  it('should update current route', async () => {
    await router.push('/about')
    expect(router.currentRoute.value.name).toBe('About')
    
    await router.push('/')
    expect(router.currentRoute.value.name).toBe('Home')
  })
})

状态管理测试

javascript
// tests/integration/store.test.js
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useUserStore } from '@/stores/user'
import { userService } from '@/services/userService'

// Mock userService
vi.mock('@/services/userService')

describe('User Store Integration', () => {
  let userStore
  
  beforeEach(() => {
    setActivePinia(createPinia())
    userStore = useUserStore()
    vi.clearAllMocks()
  })
  
  it('should fetch users successfully', async () => {
    const mockUsers = [
      { id: 1, name: 'John Doe', email: 'john@example.com' },
      { id: 2, name: 'Jane Smith', email: 'jane@example.com' }
    ]
    
    userService.getUsers = vi.fn().mockResolvedValue(mockUsers)
    
    await userStore.fetchUsers()
    
    expect(userStore.users).toEqual(mockUsers)
    expect(userStore.loading).toBe(false)
    expect(userStore.error).toBeNull()
  })
  
  it('should handle fetch users error', async () => {
    const errorMessage = 'Network error'
    userService.getUsers = vi.fn().mockRejectedValue(new Error(errorMessage))
    
    await userStore.fetchUsers()
    
    expect(userStore.users).toEqual([])
    expect(userStore.loading).toBe(false)
    expect(userStore.error).toBe(errorMessage)
  })
  
  it('should create user successfully', async () => {
    const newUser = { name: 'New User', email: 'new@example.com' }
    const createdUser = { id: 3, ...newUser }
    
    userService.createUser = vi.fn().mockResolvedValue(createdUser)
    
    await userStore.createUser(newUser)
    
    expect(userStore.users).toContain(createdUser)
    expect(userService.createUser).toHaveBeenCalledWith(newUser)
  })
  
  it('should update user successfully', async () => {
    const existingUser = { id: 1, name: 'John Doe', email: 'john@example.com' }
    userStore.users = [existingUser]
    
    const updatedData = { name: 'John Smith' }
    const updatedUser = { ...existingUser, ...updatedData }
    
    userService.updateUser = vi.fn().mockResolvedValue(updatedUser)
    
    await userStore.updateUser(1, updatedData)
    
    expect(userStore.users[0]).toEqual(updatedUser)
    expect(userService.updateUser).toHaveBeenCalledWith(1, updatedData)
  })
  
  it('should delete user successfully', async () => {
    const users = [
      { id: 1, name: 'John Doe', email: 'john@example.com' },
      { id: 2, name: 'Jane Smith', email: 'jane@example.com' }
    ]
    userStore.users = [...users]
    
    userService.deleteUser = vi.fn().mockResolvedValue(true)
    
    await userStore.deleteUser(1)
    
    expect(userStore.users).toHaveLength(1)
    expect(userStore.users[0].id).toBe(2)
    expect(userService.deleteUser).toHaveBeenCalledWith(1)
  })
})

端到端测试

Playwright 配置

javascript
// playwright.config.js
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './tests/e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:5173',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure'
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] }
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] }
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] }
    },
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 5'] }
    }
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI
  }
})

E2E 测试示例

javascript
// tests/e2e/user-management.spec.js
import { test, expect } from '@playwright/test'

test.describe('User Management', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/users')
  })
  
  test('should display user list', async ({ page }) => {
    await expect(page.locator('h1')).toContainText('用户管理')
    await expect(page.locator('[data-testid="user-list"]')).toBeVisible()
  })
  
  test('should create new user', async ({ page }) => {
    // 点击创建用户按钮
    await page.click('[data-testid="create-user-btn"]')
    
    // 填写表单
    await page.fill('#name', 'Test User')
    await page.fill('#email', 'test@example.com')
    await page.fill('#age', '25')
    
    // 提交表单
    await page.click('[data-testid="submit-btn"]')
    
    // 验证用户已创建
    await expect(page.locator('[data-testid="user-list"]')).toContainText('Test User')
    await expect(page.locator('[data-testid="success-message"]')).toContainText('用户创建成功')
  })
  
  test('should edit existing user', async ({ page }) => {
    // 点击编辑按钮
    await page.click('[data-testid="edit-user-1"]')
    
    // 修改名称
    await page.fill('#name', 'Updated User')
    
    // 提交表单
    await page.click('[data-testid="submit-btn"]')
    
    // 验证用户已更新
    await expect(page.locator('[data-testid="user-list"]')).toContainText('Updated User')
  })
  
  test('should delete user', async ({ page }) => {
    // 点击删除按钮
    await page.click('[data-testid="delete-user-1"]')
    
    // 确认删除
    await page.click('[data-testid="confirm-delete"]')
    
    // 验证用户已删除
    await expect(page.locator('[data-testid="user-1"]')).not.toBeVisible()
    await expect(page.locator('[data-testid="success-message"]')).toContainText('用户删除成功')
  })
  
  test('should validate form fields', async ({ page }) => {
    await page.click('[data-testid="create-user-btn"]')
    
    // 提交空表单
    await page.click('[data-testid="submit-btn"]')
    
    // 验证错误消息
    await expect(page.locator('.error-message')).toContainText('姓名至少需要2个字符')
    
    // 输入无效邮箱
    await page.fill('#email', 'invalid-email')
    await page.blur('#email')
    
    await expect(page.locator('.error-message')).toContainText('请输入有效的邮箱地址')
  })
  
  test('should search users', async ({ page }) => {
    // 输入搜索关键词
    await page.fill('[data-testid="search-input"]', 'John')
    
    // 验证搜索结果
    await expect(page.locator('[data-testid="user-list"] .user-item')).toHaveCount(1)
    await expect(page.locator('[data-testid="user-list"]')).toContainText('John')
  })
  
  test('should paginate users', async ({ page }) => {
    // 验证分页控件
    await expect(page.locator('[data-testid="pagination"]')).toBeVisible()
    
    // 点击下一页
    await page.click('[data-testid="next-page"]')
    
    // 验证页码变化
    await expect(page.locator('[data-testid="current-page"]')).toContainText('2')
  })
})

视觉回归测试

javascript
// tests/e2e/visual.spec.js
import { test, expect } from '@playwright/test'

test.describe('Visual Regression Tests', () => {
  test('should match homepage screenshot', async ({ page }) => {
    await page.goto('/')
    await expect(page).toHaveScreenshot('homepage.png')
  })
  
  test('should match user form screenshot', async ({ page }) => {
    await page.goto('/users/create')
    await expect(page.locator('[data-testid="user-form"]')).toHaveScreenshot('user-form.png')
  })
  
  test('should match button variants', async ({ page }) => {
    await page.goto('/components/buttons')
    
    // 测试不同状态的按钮
    await expect(page.locator('[data-testid="button-primary"]')).toHaveScreenshot('button-primary.png')
    await expect(page.locator('[data-testid="button-secondary"]')).toHaveScreenshot('button-secondary.png')
    await expect(page.locator('[data-testid="button-danger"]')).toHaveScreenshot('button-danger.png')
  })
  
  test('should match responsive layout', async ({ page }) => {
    await page.goto('/')
    
    // 桌面视图
    await page.setViewportSize({ width: 1200, height: 800 })
    await expect(page).toHaveScreenshot('desktop-layout.png')
    
    // 平板视图
    await page.setViewportSize({ width: 768, height: 1024 })
    await expect(page).toHaveScreenshot('tablet-layout.png')
    
    // 移动视图
    await page.setViewportSize({ width: 375, height: 667 })
    await expect(page).toHaveScreenshot('mobile-layout.png')
  })
})

测试工具和辅助函数

测试工具函数

javascript
// tests/utils/test-utils.js
import { mount } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import { createPinia } from 'pinia'

// 创建测试路由器
export function createTestRouter(routes = []) {
  return createRouter({
    history: createWebHistory(),
    routes
  })
}

// 创建测试 Pinia 实例
export function createTestPinia() {
  return createPinia()
}

// 挂载组件的辅助函数
export function mountComponent(component, options = {}) {
  const router = createTestRouter()
  const pinia = createTestPinia()
  
  return mount(component, {
    global: {
      plugins: [router, pinia],
      ...options.global
    },
    ...options
  })
}

// 等待异步操作完成
export async function flushPromises() {
  return new Promise(resolve => setTimeout(resolve, 0))
}

// 模拟用户输入
export async function typeInInput(wrapper, selector, text) {
  const input = wrapper.find(selector)
  await input.setValue(text)
  await input.trigger('input')
  await input.trigger('blur')
}

// 模拟表单提交
export async function submitForm(wrapper, formSelector = 'form') {
  const form = wrapper.find(formSelector)
  await form.trigger('submit')
  await flushPromises()
}

// 等待元素出现
export async function waitForElement(wrapper, selector, timeout = 1000) {
  const start = Date.now()
  
  while (Date.now() - start < timeout) {
    if (wrapper.find(selector).exists()) {
      return wrapper.find(selector)
    }
    await new Promise(resolve => setTimeout(resolve, 10))
  }
  
  throw new Error(`Element ${selector} not found within ${timeout}ms`)
}

// 模拟 API 响应
export function mockApiResponse(data, delay = 0) {
  return new Promise(resolve => {
    setTimeout(() => resolve({ data }), delay)
  })
}

// 模拟 API 错误
export function mockApiError(message, delay = 0) {
  return new Promise((_, reject) => {
    setTimeout(() => reject(new Error(message)), delay)
  })
}

Mock 数据生成器

javascript
// tests/utils/mock-data.js
import { faker } from '@faker-js/faker'

// 生成模拟用户数据
export function createMockUser(overrides = {}) {
  return {
    id: faker.number.int({ min: 1, max: 1000 }),
    name: faker.person.fullName(),
    email: faker.internet.email(),
    age: faker.number.int({ min: 18, max: 80 }),
    avatar: faker.image.avatar(),
    createdAt: faker.date.past(),
    updatedAt: faker.date.recent(),
    ...overrides
  }
}

// 生成模拟用户列表
export function createMockUsers(count = 10) {
  return Array.from({ length: count }, () => createMockUser())
}

// 生成模拟产品数据
export function createMockProduct(overrides = {}) {
  return {
    id: faker.number.int({ min: 1, max: 1000 }),
    name: faker.commerce.productName(),
    description: faker.commerce.productDescription(),
    price: parseFloat(faker.commerce.price()),
    category: faker.commerce.department(),
    image: faker.image.url(),
    inStock: faker.datatype.boolean(),
    ...overrides
  }
}

// 生成模拟 API 响应
export function createMockApiResponse(data, meta = {}) {
  return {
    data,
    meta: {
      total: Array.isArray(data) ? data.length : 1,
      page: 1,
      limit: 10,
      ...meta
    },
    success: true,
    message: 'Success'
  }
}

// 生成模拟错误响应
export function createMockErrorResponse(message = 'Something went wrong') {
  return {
    data: null,
    error: {
      message,
      code: 'GENERIC_ERROR',
      details: {}
    },
    success: false
  }
}

测试最佳实践

测试组织

  1. 测试文件结构

    tests/
    ├── unit/
    │   ├── components/
    │   ├── composables/
    │   ├── utils/
    │   └── services/
    ├── integration/
    │   ├── router/
    │   ├── store/
    │   └── api/
    ├── e2e/
    │   ├── user-flows/
    │   ├── visual/
    │   └── performance/
    └── utils/
        ├── test-utils.js
        ├── mock-data.js
        └── setup.js
  2. 命名约定

    • 测试文件:ComponentName.test.js
    • 测试套件:describe('ComponentName', () => {})
    • 测试用例:it('should do something', () => {})
  3. 测试结构

    javascript
    describe('ComponentName', () => {
      // 设置和清理
      beforeEach(() => {})
      afterEach(() => {})
      
      // 分组相关测试
      describe('when condition', () => {
        it('should behave correctly', () => {})
      })
    })

测试策略

  1. 测试金字塔

    • 70% 单元测试
    • 20% 集成测试
    • 10% 端到端测试
  2. 测试覆盖率目标

    • 语句覆盖率:> 80%
    • 分支覆盖率:> 70%
    • 函数覆盖率:> 90%
    • 行覆盖率:> 80%
  3. 测试优先级

    • 核心业务逻辑
    • 用户关键路径
    • 错误处理
    • 边界条件

编写高质量测试

  1. 测试应该是

    • 快速的
    • 独立的
    • 可重复的
    • 自验证的
    • 及时的
  2. 测试命名

    javascript
    // ✅ 好的命名
    it('should display error message when email is invalid', () => {})
    it('should disable submit button when form is invalid', () => {})
    
    // ❌ 不好的命名
    it('test email validation', () => {})
    it('button test', () => {})
  3. 断言清晰

    javascript
    // ✅ 清晰的断言
    expect(wrapper.find('.error-message').text()).toBe('邮箱格式不正确')
    expect(wrapper.emitted('submit')).toHaveLength(1)
    
    // ❌ 模糊的断言
    expect(wrapper.find('.error-message').exists()).toBe(true)
    expect(wrapper.emitted()).toBeTruthy()

持续集成

GitHub Actions 配置

yaml
# .github/workflows/test.yml
name: Test

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest
    
    strategy:
      matrix:
        node-version: [18, 20]
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Use Node.js ${{ matrix.node-version }}
      uses: actions/setup-node@v3
      with:
        node-version: ${{ matrix.node-version }}
        cache: 'npm'
    
    - name: Install dependencies
      run: npm ci
    
    - name: Run linting
      run: npm run lint
    
    - name: Run unit tests
      run: npm run test:unit
    
    - name: Run integration tests
      run: npm run test:integration
    
    - name: Upload coverage reports
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage/lcov.info
  
  e2e:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Use Node.js
      uses: actions/setup-node@v3
      with:
        node-version: 18
        cache: 'npm'
    
    - name: Install dependencies
      run: npm ci
    
    - name: Install Playwright
      run: npx playwright install --with-deps
    
    - name: Run E2E tests
      run: npm run test:e2e
    
    - name: Upload test results
      uses: actions/upload-artifact@v3
      if: failure()
      with:
        name: playwright-report
        path: playwright-report/

下一步

完善的测试策略是确保应用质量和稳定性的关键!

基于 Vue.js 官方文档构建的学习宝典