本章将深入探讨Vue.js应用的测试策略,包括单元测试、组件测试、集成测试和端到端测试,以及相关的工具和最佳实践。

8.1 测试基础

测试类型概述

// 测试金字塔结构
/*
    E2E Tests (端到端测试)
         ↑
   Integration Tests (集成测试)
         ↑
    Unit Tests (单元测试)
         ↑
    Static Analysis (静态分析)
*/

// 1. 静态分析:ESLint, TypeScript, Prettier
// 2. 单元测试:测试独立的函数和组件
// 3. 集成测试:测试组件间的交互
// 4. E2E测试:测试完整的用户流程

测试环境搭建

# 安装测试依赖
npm install --save-dev vitest @vue/test-utils jsdom
npm install --save-dev @testing-library/vue @testing-library/jest-dom
npm install --save-dev cypress @cypress/vue
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src')
    }
  },
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.js']
  }
})
// src/test/setup.js
import { expect, afterEach } from 'vitest'
import { cleanup } from '@testing-library/vue'
import * as matchers from '@testing-library/jest-dom/matchers'

// 扩展expect匹配器
expect.extend(matchers)

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

// 全局测试配置
global.ResizeObserver = class ResizeObserver {
  constructor(cb) {
    this.cb = cb
  }
  observe() {
    this.cb([{ borderBoxSize: { inlineSize: 0, blockSize: 0 } }], this)
  }
  unobserve() {}
  disconnect() {}
}
// package.json
{
  "scripts": {
    "test": "vitest",
    "test:ui": "vitest --ui",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage",
    "test:e2e": "cypress open",
    "test:e2e:headless": "cypress run"
  }
}

8.2 单元测试

工具函数测试

// src/utils/format.js
export function formatCurrency(amount, currency = 'CNY') {
  if (typeof amount !== 'number' || isNaN(amount)) {
    throw new Error('Amount must be a valid number')
  }
  
  return new Intl.NumberFormat('zh-CN', {
    style: 'currency',
    currency: currency
  }).format(amount)
}

export function formatDate(date, format = 'YYYY-MM-DD') {
  if (!date) return ''
  
  const d = new Date(date)
  if (isNaN(d.getTime())) {
    throw new Error('Invalid date')
  }
  
  const year = d.getFullYear()
  const month = String(d.getMonth() + 1).padStart(2, '0')
  const day = String(d.getDate()).padStart(2, '0')
  
  switch (format) {
    case 'YYYY-MM-DD':
      return `${year}-${month}-${day}`
    case 'MM/DD/YYYY':
      return `${month}/${day}/${year}`
    case 'DD/MM/YYYY':
      return `${day}/${month}/${year}`
    default:
      return `${year}-${month}-${day}`
  }
}

export function debounce(func, wait) {
  let timeout
  return function executedFunction(...args) {
    const later = () => {
      clearTimeout(timeout)
      func(...args)
    }
    clearTimeout(timeout)
    timeout = setTimeout(later, wait)
  }
}

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

export function deepClone(obj) {
  if (obj === null || typeof obj !== 'object') {
    return obj
  }
  
  if (obj instanceof Date) {
    return new Date(obj.getTime())
  }
  
  if (obj instanceof Array) {
    return obj.map(item => deepClone(item))
  }
  
  if (typeof obj === 'object') {
    const cloned = {}
    for (const key in obj) {
      if (obj.hasOwnProperty(key)) {
        cloned[key] = deepClone(obj[key])
      }
    }
    return cloned
  }
}
// src/utils/__tests__/format.test.js
import { describe, it, expect, vi } from 'vitest'
import {
  formatCurrency,
  formatDate,
  debounce,
  validateEmail,
  deepClone
} from '../format'

describe('formatCurrency', () => {
  it('should format number as currency', () => {
    expect(formatCurrency(1234.56)).toBe('¥1,234.56')
    expect(formatCurrency(0)).toBe('¥0.00')
    expect(formatCurrency(1000000)).toBe('¥1,000,000.00')
  })
  
  it('should handle different currencies', () => {
    expect(formatCurrency(100, 'USD')).toContain('$')
    expect(formatCurrency(100, 'EUR')).toContain('€')
  })
  
  it('should throw error for invalid input', () => {
    expect(() => formatCurrency('invalid')).toThrow('Amount must be a valid number')
    expect(() => formatCurrency(NaN)).toThrow('Amount must be a valid number')
    expect(() => formatCurrency(null)).toThrow('Amount must be a valid number')
  })
})

describe('formatDate', () => {
  it('should format date with default format', () => {
    const date = new Date('2023-12-25')
    expect(formatDate(date)).toBe('2023-12-25')
  })
  
  it('should format date with custom formats', () => {
    const date = new Date('2023-12-25')
    expect(formatDate(date, 'MM/DD/YYYY')).toBe('12/25/2023')
    expect(formatDate(date, 'DD/MM/YYYY')).toBe('25/12/2023')
  })
  
  it('should handle string dates', () => {
    expect(formatDate('2023-12-25')).toBe('2023-12-25')
    expect(formatDate('2023/12/25')).toBe('2023-12-25')
  })
  
  it('should return empty string for falsy values', () => {
    expect(formatDate(null)).toBe('')
    expect(formatDate(undefined)).toBe('')
    expect(formatDate('')).toBe('')
  })
  
  it('should throw error for invalid dates', () => {
    expect(() => formatDate('invalid-date')).toThrow('Invalid date')
    expect(() => formatDate('2023-13-45')).toThrow('Invalid date')
  })
})

describe('debounce', () => {
  it('should debounce function calls', async () => {
    const mockFn = vi.fn()
    const debouncedFn = debounce(mockFn, 100)
    
    // 快速调用多次
    debouncedFn('arg1')
    debouncedFn('arg2')
    debouncedFn('arg3')
    
    // 函数还未被调用
    expect(mockFn).not.toHaveBeenCalled()
    
    // 等待debounce时间
    await new Promise(resolve => setTimeout(resolve, 150))
    
    // 函数应该只被调用一次,使用最后的参数
    expect(mockFn).toHaveBeenCalledTimes(1)
    expect(mockFn).toHaveBeenCalledWith('arg3')
  })
  
  it('should handle multiple separate calls', async () => {
    const mockFn = vi.fn()
    const debouncedFn = debounce(mockFn, 50)
    
    debouncedFn('first')
    await new Promise(resolve => setTimeout(resolve, 100))
    
    debouncedFn('second')
    await new Promise(resolve => setTimeout(resolve, 100))
    
    expect(mockFn).toHaveBeenCalledTimes(2)
    expect(mockFn).toHaveBeenNthCalledWith(1, 'first')
    expect(mockFn).toHaveBeenNthCalledWith(2, 'second')
  })
})

describe('validateEmail', () => {
  it('should validate correct email addresses', () => {
    expect(validateEmail('test@example.com')).toBe(true)
    expect(validateEmail('user.name@domain.co.uk')).toBe(true)
    expect(validateEmail('user+tag@example.org')).toBe(true)
  })
  
  it('should reject invalid email addresses', () => {
    expect(validateEmail('invalid-email')).toBe(false)
    expect(validateEmail('test@')).toBe(false)
    expect(validateEmail('@example.com')).toBe(false)
    expect(validateEmail('test..test@example.com')).toBe(false)
    expect(validateEmail('')).toBe(false)
  })
})

describe('deepClone', () => {
  it('should clone primitive values', () => {
    expect(deepClone(42)).toBe(42)
    expect(deepClone('string')).toBe('string')
    expect(deepClone(true)).toBe(true)
    expect(deepClone(null)).toBe(null)
    expect(deepClone(undefined)).toBe(undefined)
  })
  
  it('should clone arrays', () => {
    const original = [1, 2, [3, 4]]
    const cloned = deepClone(original)
    
    expect(cloned).toEqual(original)
    expect(cloned).not.toBe(original)
    expect(cloned[2]).not.toBe(original[2])
  })
  
  it('should clone objects', () => {
    const original = {
      name: 'John',
      age: 30,
      address: {
        city: 'New York',
        country: 'USA'
      }
    }
    const cloned = deepClone(original)
    
    expect(cloned).toEqual(original)
    expect(cloned).not.toBe(original)
    expect(cloned.address).not.toBe(original.address)
  })
  
  it('should clone dates', () => {
    const original = new Date('2023-12-25')
    const cloned = deepClone(original)
    
    expect(cloned).toEqual(original)
    expect(cloned).not.toBe(original)
    expect(cloned instanceof Date).toBe(true)
  })
  
  it('should handle complex nested structures', () => {
    const original = {
      users: [
        {
          id: 1,
          name: 'John',
          createdAt: new Date('2023-01-01'),
          settings: {
            theme: 'dark',
            notifications: true
          }
        }
      ],
      metadata: {
        version: '1.0.0',
        lastUpdated: new Date('2023-12-25')
      }
    }
    const cloned = deepClone(original)
    
    expect(cloned).toEqual(original)
    expect(cloned).not.toBe(original)
    expect(cloned.users[0]).not.toBe(original.users[0])
    expect(cloned.users[0].settings).not.toBe(original.users[0].settings)
  })
})

Composables测试

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

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  
  const doubleCount = computed(() => count.value * 2)
  const isEven = computed(() => count.value % 2 === 0)
  
  function increment() {
    count.value++
  }
  
  function decrement() {
    count.value--
  }
  
  function reset() {
    count.value = initialValue
  }
  
  function setValue(value) {
    if (typeof value !== 'number') {
      throw new Error('Value must be a number')
    }
    count.value = value
  }
  
  return {
    count,
    doubleCount,
    isEven,
    increment,
    decrement,
    reset,
    setValue
  }
}
// src/composables/__tests__/useCounter.test.js
import { describe, it, expect } from 'vitest'
import { useCounter } from '../useCounter'

describe('useCounter', () => {
  it('should initialize with default value', () => {
    const { count, doubleCount, isEven } = useCounter()
    
    expect(count.value).toBe(0)
    expect(doubleCount.value).toBe(0)
    expect(isEven.value).toBe(true)
  })
  
  it('should initialize with custom value', () => {
    const { count, doubleCount, isEven } = useCounter(5)
    
    expect(count.value).toBe(5)
    expect(doubleCount.value).toBe(10)
    expect(isEven.value).toBe(false)
  })
  
  it('should increment count', () => {
    const { count, increment } = useCounter(0)
    
    increment()
    expect(count.value).toBe(1)
    
    increment()
    expect(count.value).toBe(2)
  })
  
  it('should decrement count', () => {
    const { count, decrement } = useCounter(5)
    
    decrement()
    expect(count.value).toBe(4)
    
    decrement()
    expect(count.value).toBe(3)
  })
  
  it('should reset to initial value', () => {
    const { count, increment, reset } = useCounter(10)
    
    increment()
    increment()
    expect(count.value).toBe(12)
    
    reset()
    expect(count.value).toBe(10)
  })
  
  it('should set value correctly', () => {
    const { count, setValue } = useCounter()
    
    setValue(42)
    expect(count.value).toBe(42)
  })
  
  it('should throw error for invalid setValue input', () => {
    const { setValue } = useCounter()
    
    expect(() => setValue('invalid')).toThrow('Value must be a number')
    expect(() => setValue(null)).toThrow('Value must be a number')
    expect(() => setValue(undefined)).toThrow('Value must be a number')
  })
  
  it('should update computed values reactively', () => {
    const { count, doubleCount, isEven, increment } = useCounter(0)
    
    expect(doubleCount.value).toBe(0)
    expect(isEven.value).toBe(true)
    
    increment()
    expect(doubleCount.value).toBe(2)
    expect(isEven.value).toBe(false)
    
    increment()
    expect(doubleCount.value).toBe(4)
    expect(isEven.value).toBe(true)
  })
})

API服务测试

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

const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api'

const api = axios.create({
  baseURL: API_BASE_URL,
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json'
  }
})

// 请求拦截器
api.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('authToken')
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  (error) => {
    return Promise.reject(error)
  }
)

// 响应拦截器
api.interceptors.response.use(
  (response) => response.data,
  (error) => {
    if (error.response?.status === 401) {
      localStorage.removeItem('authToken')
      window.location.href = '/login'
    }
    return Promise.reject(error)
  }
)

export class UserService {
  static async getUsers(params = {}) {
    try {
      const response = await api.get('/users', { params })
      return response
    } catch (error) {
      throw new Error(`Failed to fetch users: ${error.message}`)
    }
  }
  
  static async getUserById(id) {
    if (!id) {
      throw new Error('User ID is required')
    }
    
    try {
      const response = await api.get(`/users/${id}`)
      return response
    } catch (error) {
      if (error.response?.status === 404) {
        throw new Error('User not found')
      }
      throw new Error(`Failed to fetch user: ${error.message}`)
    }
  }
  
  static async createUser(userData) {
    if (!userData || !userData.email) {
      throw new Error('User email is required')
    }
    
    try {
      const response = await api.post('/users', userData)
      return response
    } catch (error) {
      if (error.response?.status === 409) {
        throw new Error('User already exists')
      }
      throw new Error(`Failed to create user: ${error.message}`)
    }
  }
  
  static async updateUser(id, userData) {
    if (!id) {
      throw new Error('User ID is required')
    }
    
    try {
      const response = await api.put(`/users/${id}`, userData)
      return response
    } catch (error) {
      if (error.response?.status === 404) {
        throw new Error('User not found')
      }
      throw new Error(`Failed to update user: ${error.message}`)
    }
  }
  
  static async deleteUser(id) {
    if (!id) {
      throw new Error('User ID is required')
    }
    
    try {
      await api.delete(`/users/${id}`)
      return true
    } catch (error) {
      if (error.response?.status === 404) {
        throw new Error('User not found')
      }
      throw new Error(`Failed to delete user: ${error.message}`)
    }
  }
}
// src/services/__tests__/userService.test.js
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import axios from 'axios'
import { UserService } from '../userService'

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

// Mock localStorage
const localStorageMock = {
  getItem: vi.fn(),
  setItem: vi.fn(),
  removeItem: vi.fn()
}
Object.defineProperty(window, 'localStorage', {
  value: localStorageMock
})

// Mock window.location
const locationMock = {
  href: ''
}
Object.defineProperty(window, 'location', {
  value: locationMock,
  writable: true
})

describe('UserService', () => {
  const mockApi = {
    get: vi.fn(),
    post: vi.fn(),
    put: vi.fn(),
    delete: vi.fn(),
    interceptors: {
      request: { use: vi.fn() },
      response: { use: vi.fn() }
    }
  }
  
  beforeEach(() => {
    mockedAxios.create.mockReturnValue(mockApi)
    vi.clearAllMocks()
  })
  
  afterEach(() => {
    vi.resetAllMocks()
  })
  
  describe('getUsers', () => {
    it('should fetch users successfully', async () => {
      const mockUsers = [
        { id: 1, name: 'John', email: 'john@example.com' },
        { id: 2, name: 'Jane', email: 'jane@example.com' }
      ]
      
      mockApi.get.mockResolvedValue(mockUsers)
      
      const result = await UserService.getUsers()
      
      expect(mockApi.get).toHaveBeenCalledWith('/users', { params: {} })
      expect(result).toEqual(mockUsers)
    })
    
    it('should fetch users with parameters', async () => {
      const params = { page: 1, limit: 10, search: 'john' }
      const mockUsers = [{ id: 1, name: 'John', email: 'john@example.com' }]
      
      mockApi.get.mockResolvedValue(mockUsers)
      
      const result = await UserService.getUsers(params)
      
      expect(mockApi.get).toHaveBeenCalledWith('/users', { params })
      expect(result).toEqual(mockUsers)
    })
    
    it('should handle fetch users error', async () => {
      const errorMessage = 'Network Error'
      mockApi.get.mockRejectedValue(new Error(errorMessage))
      
      await expect(UserService.getUsers()).rejects.toThrow(
        `Failed to fetch users: ${errorMessage}`
      )
    })
  })
  
  describe('getUserById', () => {
    it('should fetch user by id successfully', async () => {
      const mockUser = { id: 1, name: 'John', email: 'john@example.com' }
      mockApi.get.mockResolvedValue(mockUser)
      
      const result = await UserService.getUserById(1)
      
      expect(mockApi.get).toHaveBeenCalledWith('/users/1')
      expect(result).toEqual(mockUser)
    })
    
    it('should throw error for missing id', async () => {
      await expect(UserService.getUserById()).rejects.toThrow(
        'User ID is required'
      )
      await expect(UserService.getUserById(null)).rejects.toThrow(
        'User ID is required'
      )
    })
    
    it('should handle user not found error', async () => {
      const error = new Error('Not Found')
      error.response = { status: 404 }
      mockApi.get.mockRejectedValue(error)
      
      await expect(UserService.getUserById(999)).rejects.toThrow(
        'User not found'
      )
    })
    
    it('should handle other errors', async () => {
      const errorMessage = 'Server Error'
      mockApi.get.mockRejectedValue(new Error(errorMessage))
      
      await expect(UserService.getUserById(1)).rejects.toThrow(
        `Failed to fetch user: ${errorMessage}`
      )
    })
  })
  
  describe('createUser', () => {
    it('should create user successfully', async () => {
      const userData = { name: 'John', email: 'john@example.com' }
      const mockCreatedUser = { id: 1, ...userData }
      
      mockApi.post.mockResolvedValue(mockCreatedUser)
      
      const result = await UserService.createUser(userData)
      
      expect(mockApi.post).toHaveBeenCalledWith('/users', userData)
      expect(result).toEqual(mockCreatedUser)
    })
    
    it('should throw error for missing email', async () => {
      await expect(UserService.createUser()).rejects.toThrow(
        'User email is required'
      )
      await expect(UserService.createUser({})).rejects.toThrow(
        'User email is required'
      )
      await expect(UserService.createUser({ name: 'John' })).rejects.toThrow(
        'User email is required'
      )
    })
    
    it('should handle user already exists error', async () => {
      const userData = { name: 'John', email: 'john@example.com' }
      const error = new Error('Conflict')
      error.response = { status: 409 }
      
      mockApi.post.mockRejectedValue(error)
      
      await expect(UserService.createUser(userData)).rejects.toThrow(
        'User already exists'
      )
    })
  })
  
  describe('updateUser', () => {
    it('should update user successfully', async () => {
      const userData = { name: 'John Updated' }
      const mockUpdatedUser = { id: 1, name: 'John Updated', email: 'john@example.com' }
      
      mockApi.put.mockResolvedValue(mockUpdatedUser)
      
      const result = await UserService.updateUser(1, userData)
      
      expect(mockApi.put).toHaveBeenCalledWith('/users/1', userData)
      expect(result).toEqual(mockUpdatedUser)
    })
    
    it('should throw error for missing id', async () => {
      await expect(UserService.updateUser()).rejects.toThrow(
        'User ID is required'
      )
    })
    
    it('should handle user not found error', async () => {
      const error = new Error('Not Found')
      error.response = { status: 404 }
      mockApi.put.mockRejectedValue(error)
      
      await expect(UserService.updateUser(999, {})).rejects.toThrow(
        'User not found'
      )
    })
  })
  
  describe('deleteUser', () => {
    it('should delete user successfully', async () => {
      mockApi.delete.mockResolvedValue()
      
      const result = await UserService.deleteUser(1)
      
      expect(mockApi.delete).toHaveBeenCalledWith('/users/1')
      expect(result).toBe(true)
    })
    
    it('should throw error for missing id', async () => {
      await expect(UserService.deleteUser()).rejects.toThrow(
        'User ID is required'
      )
    })
    
    it('should handle user not found error', async () => {
      const error = new Error('Not Found')
      error.response = { status: 404 }
      mockApi.delete.mockRejectedValue(error)
      
      await expect(UserService.deleteUser(999)).rejects.toThrow(
        'User not found'
      )
    })
  })
})

8.3 组件测试

基础组件测试

<!-- src/components/Button.vue -->
<template>
  <button
    :class="[
      'btn',
      `btn-${variant}`,
      `btn-${size}`,
      {
        'btn-loading': loading,
        'btn-disabled': disabled
      }
    ]"
    :disabled="disabled || loading"
    @click="handleClick"
  >
    <span v-if="loading" class="btn-spinner"></span>
    <slot v-if="!loading" />
    <span v-if="loading">{{ loadingText }}</span>
  </button>
</template>

<script>
export default {
  name: 'Button',
  
  props: {
    variant: {
      type: String,
      default: 'primary',
      validator: (value) => ['primary', 'secondary', 'success', 'danger', 'warning'].includes(value)
    },
    size: {
      type: String,
      default: 'medium',
      validator: (value) => ['small', 'medium', 'large'].includes(value)
    },
    disabled: {
      type: Boolean,
      default: false
    },
    loading: {
      type: Boolean,
      default: false
    },
    loadingText: {
      type: String,
      default: '加载中...'
    }
  },
  
  emits: ['click'],
  
  methods: {
    handleClick(event) {
      if (!this.disabled && !this.loading) {
        this.$emit('click', event)
      }
    }
  }
}
</script>

<style scoped>
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0.75rem 1.5rem;
  border: none;
  border-radius: 4px;
  font-size: 1rem;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.3s;
  text-decoration: none;
  user-select: none;
}

.btn:focus {
  outline: none;
  box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}

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

.btn-primary:hover:not(:disabled) {
  background: #0056b3;
}

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

.btn-success {
  background: #28a745;
  color: white;
}

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

.btn-warning {
  background: #ffc107;
  color: #212529;
}

.btn-small {
  padding: 0.5rem 1rem;
  font-size: 0.875rem;
}

.btn-large {
  padding: 1rem 2rem;
  font-size: 1.125rem;
}

.btn-disabled,
.btn:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.btn-loading {
  cursor: wait;
}

.btn-spinner {
  width: 1rem;
  height: 1rem;
  border: 2px solid transparent;
  border-top: 2px solid currentColor;
  border-radius: 50%;
  animation: spin 1s linear infinite;
  margin-right: 0.5rem;
}

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

describe('Button', () => {
  it('should render with default props', () => {
    const wrapper = mount(Button, {
      slots: {
        default: 'Click me'
      }
    })
    
    expect(wrapper.text()).toBe('Click me')
    expect(wrapper.classes()).toContain('btn')
    expect(wrapper.classes()).toContain('btn-primary')
    expect(wrapper.classes()).toContain('btn-medium')
  })
  
  it('should render different variants', () => {
    const variants = ['primary', 'secondary', 'success', 'danger', 'warning']
    
    variants.forEach(variant => {
      const wrapper = mount(Button, {
        props: { variant },
        slots: { default: 'Button' }
      })
      
      expect(wrapper.classes()).toContain(`btn-${variant}`)
    })
  })
  
  it('should render different sizes', () => {
    const sizes = ['small', 'medium', 'large']
    
    sizes.forEach(size => {
      const wrapper = mount(Button, {
        props: { size },
        slots: { default: 'Button' }
      })
      
      expect(wrapper.classes()).toContain(`btn-${size}`)
    })
  })
  
  it('should handle disabled state', () => {
    const wrapper = mount(Button, {
      props: { disabled: true },
      slots: { default: 'Button' }
    })
    
    expect(wrapper.classes()).toContain('btn-disabled')
    expect(wrapper.attributes('disabled')).toBeDefined()
  })
  
  it('should handle loading state', () => {
    const wrapper = mount(Button, {
      props: { loading: true, loadingText: 'Loading...' },
      slots: { default: 'Button' }
    })
    
    expect(wrapper.classes()).toContain('btn-loading')
    expect(wrapper.text()).toBe('Loading...')
    expect(wrapper.find('.btn-spinner').exists()).toBe(true)
    expect(wrapper.attributes('disabled')).toBeDefined()
  })
  
  it('should emit click event when clicked', async () => {
    const wrapper = mount(Button, {
      slots: { default: 'Button' }
    })
    
    await wrapper.trigger('click')
    
    expect(wrapper.emitted('click')).toBeTruthy()
    expect(wrapper.emitted('click')).toHaveLength(1)
  })
  
  it('should not emit click when disabled', async () => {
    const wrapper = mount(Button, {
      props: { disabled: true },
      slots: { default: 'Button' }
    })
    
    await wrapper.trigger('click')
    
    expect(wrapper.emitted('click')).toBeFalsy()
  })
  
  it('should not emit click when loading', async () => {
    const wrapper = mount(Button, {
      props: { loading: true },
      slots: { default: 'Button' }
    })
    
    await wrapper.trigger('click')
    
    expect(wrapper.emitted('click')).toBeFalsy()
  })
  
  it('should pass click event to handler', async () => {
    const clickHandler = vi.fn()
    const wrapper = mount(Button, {
      slots: { default: 'Button' }
    })
    
    wrapper.vm.$on('click', clickHandler)
    await wrapper.trigger('click')
    
    expect(wrapper.emitted('click')[0][0]).toBeInstanceOf(Event)
  })
  
  it('should validate variant prop', () => {
    const validator = Button.props.variant.validator
    
    expect(validator('primary')).toBe(true)
    expect(validator('secondary')).toBe(true)
    expect(validator('invalid')).toBe(false)
  })
  
  it('should validate size prop', () => {
    const validator = Button.props.size.validator
    
    expect(validator('small')).toBe(true)
    expect(validator('medium')).toBe(true)
    expect(validator('large')).toBe(true)
    expect(validator('invalid')).toBe(false)
  })
})

复杂组件测试

<!-- 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="form-control"
        :class="{ 'is-invalid': errors.name }"
        @blur="validateField('name')"
      >
      <div v-if="errors.name" class="invalid-feedback">
        {{ errors.name }}
      </div>
    </div>
    
    <div class="form-group">
      <label for="email">邮箱 *</label>
      <input
        id="email"
        v-model="form.email"
        type="email"
        class="form-control"
        :class="{ 'is-invalid': errors.email }"
        @blur="validateField('email')"
      >
      <div v-if="errors.email" class="invalid-feedback">
        {{ errors.email }}
      </div>
    </div>
    
    <div class="form-group">
      <label for="role">角色</label>
      <select
        id="role"
        v-model="form.role"
        class="form-control"
      >
        <option value="">请选择角色</option>
        <option value="admin">管理员</option>
        <option value="user">普通用户</option>
        <option value="guest">访客</option>
      </select>
    </div>
    
    <div class="form-group">
      <label>
        <input
          v-model="form.active"
          type="checkbox"
        >
        激活用户
      </label>
    </div>
    
    <div class="form-actions">
      <Button
        type="submit"
        :loading="loading"
        :disabled="!isFormValid"
        variant="primary"
      >
        {{ isEditing ? '更新' : '创建' }}
      </Button>
      
      <Button
        type="button"
        variant="secondary"
        @click="handleCancel"
      >
        取消
      </Button>
    </div>
  </form>
</template>

<script>
import { ref, computed, watch } from 'vue'
import Button from './Button.vue'
import { validateEmail } from '@/utils/format'

export default {
  name: 'UserForm',
  
  components: {
    Button
  },
  
  props: {
    user: {
      type: Object,
      default: () => ({})
    },
    loading: {
      type: Boolean,
      default: false
    }
  },
  
  emits: ['submit', 'cancel'],
  
  setup(props, { emit }) {
    const form = ref({
      name: '',
      email: '',
      role: '',
      active: true
    })
    
    const errors = ref({})
    
    const isEditing = computed(() => {
      return props.user && props.user.id
    })
    
    const isFormValid = computed(() => {
      return form.value.name.trim() && 
             form.value.email.trim() && 
             validateEmail(form.value.email) &&
             Object.keys(errors.value).length === 0
    })
    
    // 监听用户数据变化
    watch(
      () => props.user,
      (newUser) => {
        if (newUser) {
          form.value = {
            name: newUser.name || '',
            email: newUser.email || '',
            role: newUser.role || '',
            active: newUser.active !== false
          }
        }
      },
      { immediate: true }
    )
    
    function validateField(field) {
      const value = form.value[field]
      
      switch (field) {
        case 'name':
          if (!value || !value.trim()) {
            errors.value.name = '姓名不能为空'
          } else if (value.trim().length < 2) {
            errors.value.name = '姓名至少需要2个字符'
          } else {
            delete errors.value.name
          }
          break
          
        case 'email':
          if (!value || !value.trim()) {
            errors.value.email = '邮箱不能为空'
          } else if (!validateEmail(value)) {
            errors.value.email = '请输入有效的邮箱地址'
          } else {
            delete errors.value.email
          }
          break
      }
    }
    
    function validateForm() {
      validateField('name')
      validateField('email')
      return Object.keys(errors.value).length === 0
    }
    
    function handleSubmit() {
      if (validateForm()) {
        emit('submit', { ...form.value })
      }
    }
    
    function handleCancel() {
      emit('cancel')
    }
    
    return {
      form,
      errors,
      isEditing,
      isFormValid,
      validateField,
      handleSubmit,
      handleCancel
    }
  }
}
</script>

<style scoped>
.user-form {
  max-width: 500px;
  margin: 0 auto;
}

.form-group {
  margin-bottom: 1.5rem;
}

.form-group label {
  display: block;
  margin-bottom: 0.5rem;
  font-weight: 500;
  color: #333;
}

.form-control {
  width: 100%;
  padding: 0.75rem;
  border: 1px solid #ced4da;
  border-radius: 4px;
  font-size: 1rem;
  transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}

.form-control:focus {
  outline: none;
  border-color: #80bdff;
  box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}

.form-control.is-invalid {
  border-color: #dc3545;
}

.invalid-feedback {
  display: block;
  width: 100%;
  margin-top: 0.25rem;
  font-size: 0.875rem;
  color: #dc3545;
}

.form-actions {
  display: flex;
  gap: 1rem;
  justify-content: flex-end;
  margin-top: 2rem;
}
</style>
// src/components/__tests__/UserForm.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import UserForm from '../UserForm.vue'
import Button from '../Button.vue'

// Mock utils
vi.mock('@/utils/format', () => ({
  validateEmail: vi.fn((email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
}))

describe('UserForm', () => {
  let wrapper
  
  beforeEach(() => {
    wrapper = mount(UserForm, {
      global: {
        components: {
          Button
        }
      }
    })
  })
  
  it('should render form fields', () => {
    expect(wrapper.find('#name').exists()).toBe(true)
    expect(wrapper.find('#email').exists()).toBe(true)
    expect(wrapper.find('#role').exists()).toBe(true)
    expect(wrapper.find('input[type="checkbox"]').exists()).toBe(true)
  })
  
  it('should initialize with empty form', () => {
    expect(wrapper.find('#name').element.value).toBe('')
    expect(wrapper.find('#email').element.value).toBe('')
    expect(wrapper.find('#role').element.value).toBe('')
    expect(wrapper.find('input[type="checkbox"]').element.checked).toBe(true)
  })
  
  it('should populate form with user data', async () => {
    const user = {
      id: 1,
      name: 'John Doe',
      email: 'john@example.com',
      role: 'admin',
      active: false
    }
    
    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('#role').element.value).toBe('admin')
    expect(wrapper.find('input[type="checkbox"]').element.checked).toBe(false)
  })
  
  it('should show editing mode for existing user', async () => {
    const user = { id: 1, name: 'John', email: 'john@example.com' }
    await wrapper.setProps({ user })
    
    const submitButton = wrapper.findComponent(Button)
    expect(submitButton.text()).toBe('更新')
  })
  
  it('should show create mode for new user', () => {
    const submitButton = wrapper.findComponent(Button)
    expect(submitButton.text()).toBe('创建')
  })
  
  it('should validate required fields', async () => {
    const nameInput = wrapper.find('#name')
    const emailInput = wrapper.find('#email')
    
    // 触发验证
    await nameInput.trigger('blur')
    await emailInput.trigger('blur')
    
    expect(wrapper.find('.invalid-feedback').exists()).toBe(true)
    expect(wrapper.text()).toContain('姓名不能为空')
    expect(wrapper.text()).toContain('邮箱不能为空')
  })
  
  it('should validate email format', async () => {
    const emailInput = wrapper.find('#email')
    
    await emailInput.setValue('invalid-email')
    await emailInput.trigger('blur')
    
    expect(wrapper.text()).toContain('请输入有效的邮箱地址')
  })
  
  it('should validate name length', async () => {
    const nameInput = wrapper.find('#name')
    
    await nameInput.setValue('a')
    await nameInput.trigger('blur')
    
    expect(wrapper.text()).toContain('姓名至少需要2个字符')
  })
  
  it('should disable submit button when form is invalid', async () => {
    const submitButton = wrapper.findComponent(Button)
    expect(submitButton.props('disabled')).toBe(true)
  })
  
  it('should enable submit button when form is valid', async () => {
    await wrapper.find('#name').setValue('John Doe')
    await wrapper.find('#email').setValue('john@example.com')
    
    const submitButton = wrapper.findComponent(Button)
    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('#role').setValue('admin')
    
    const form = wrapper.find('form')
    await form.trigger('submit')
    
    expect(wrapper.emitted('submit')).toBeTruthy()
    expect(wrapper.emitted('submit')[0][0]).toEqual({
      name: 'John Doe',
      email: 'john@example.com',
      role: 'admin',
      active: true
    })
  })
  
  it('should emit cancel event', async () => {
    const cancelButton = wrapper.findAllComponents(Button)[1]
    await cancelButton.trigger('click')
    
    expect(wrapper.emitted('cancel')).toBeTruthy()
  })
  
  it('should show loading state', async () => {
    await wrapper.setProps({ loading: true })
    
    const submitButton = wrapper.findComponent(Button)
    expect(submitButton.props('loading')).toBe(true)
  })
  
  it('should handle checkbox changes', async () => {
    const checkbox = wrapper.find('input[type="checkbox"]')
    
    await checkbox.setChecked(false)
    expect(checkbox.element.checked).toBe(false)
    
    await checkbox.setChecked(true)
    expect(checkbox.element.checked).toBe(true)
  })
  
  it('should clear errors when field becomes valid', async () => {
    const nameInput = wrapper.find('#name')
    
    // 先触发错误
    await nameInput.trigger('blur')
    expect(wrapper.text()).toContain('姓名不能为空')
    
    // 输入有效值
    await nameInput.setValue('John Doe')
    await nameInput.trigger('blur')
    
    expect(wrapper.text()).not.toContain('姓名不能为空')
  })
})

本章小结

本章我们学习了Vue.js应用的测试策略:

  1. 测试基础:了解测试类型和环境搭建
  2. 单元测试:测试工具函数、Composables和API服务
  3. 组件测试:测试Vue组件的行为和交互
  4. 测试工具:掌握Vitest、Vue Test Utils等工具的使用

下一章预告

下一章我们将学习Vue.js的性能优化和部署策略,包括: - 性能分析和优化技巧 - 构建优化和代码分割 - 部署策略和CI/CD - 监控和错误追踪

练习题

基础练习

  1. 工具函数测试

    • 为日期格式化函数编写测试
    • 测试表单验证函数
    • 编写防抖函数的测试
  2. 组件测试

    • 测试输入组件的验证逻辑
    • 测试模态框组件的显示隐藏
    • 测试列表组件的排序功能

进阶练习

  1. 集成测试

    • 测试表单提交流程
    • 测试用户登录流程
    • 测试数据加载和错误处理
  2. 端到端测试

    • 编写用户注册流程的E2E测试
    • 测试购物车功能
    • 测试搜索和筛选功能

提示:良好的测试策略能够提高代码质量,减少bug,并为重构提供信心。