HiHuo
首页
博客
手册
工具
首页
博客
手册
工具
  • Vue & CSS 进阶

    • 第0章:工具链与开发体验
    • 第1章:TypeScript 核心
    • 第2章:CSS 现代能力
    • 第3章:Vue3 核心与原理
    • 第4章:路由与状态管理
    • 第5章:工程化与性能
    • 第6章:测试与质量
    • 第7章:CSS 进阶专题
    • 第8章:项目实战A - SaaS仪表盘
    • 第9章:项目实战B - 内容社区
    • 第10章:Vue 内核深入
    • 第11章:微前端与部署
    • CSS 变量体系与主题系统深度指南
    • 前端安全与防护专题
    • CSS 专题:动效优化与微交互
    • CSS 专题:图片与图形处理
    • 实时通信与WebSocket专题
    • 开发工具与调试技巧专题
    • 新兴技术与未来趋势专题
    • 移动端开发与PWA专题
    • 附录A:代码片段库
    • 附录B:练习题集

第6章:测试与质量

掌握现代前端测试策略,确保代码质量和应用稳定性

📚 本章目标

通过本章学习,你将掌握:

  • Vitest 单元测试框架
  • Vue Test Utils 组件测试
  • Playwright 端到端测试
  • 测试驱动开发 (TDD)
  • 测试最佳实践

🧪 6.1 测试基础

6.1.1 测试金字塔

        /\
       /  \
      / E2E \     <- 少量,覆盖关键用户流程
     /______\
    /        \
   / 集成测试  \   <- 中等数量,测试模块间交互
  /____________\
 /              \
/   单元测试     \  <- 大量,测试单个函数/组件
/________________\

6.1.2 测试类型

  • 单元测试:测试单个函数或组件
  • 集成测试:测试多个模块的交互
  • 端到端测试:测试完整的用户流程
  • 视觉回归测试:测试 UI 变化

⚡ 6.2 Vitest 单元测试

6.2.1 环境配置

# 安装依赖
pnpm add -D vitest @vue/test-utils jsdom @testing-library/vue
// vitest.config.ts
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: ['./test/setup.ts']
  },
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src')
    }
  }
})
// test/setup.ts
import { config } from '@vue/test-utils'

// 全局配置
config.global.stubs = {
  'router-link': true,
  'router-view': true
}

// 模拟全局属性
config.global.mocks = {
  $t: (key: string) => key
}

6.2.2 基础测试

// test/utils.test.ts
import { describe, it, expect } from 'vitest'

// 工具函数测试
export function formatDate(date: Date): string {
  return date.toLocaleDateString('zh-CN')
}

export function calculateTotal(items: Array<{ price: number; quantity: number }>): number {
  return items.reduce((total, item) => total + item.price * item.quantity, 0)
}

describe('工具函数测试', () => {
  describe('formatDate', () => {
    it('应该正确格式化日期', () => {
      const date = new Date('2024-01-01')
      expect(formatDate(date)).toBe('2024/1/1')
    })
  })

  describe('calculateTotal', () => {
    it('应该正确计算总价', () => {
      const items = [
        { price: 10, quantity: 2 },
        { price: 5, quantity: 3 }
      ]
      expect(calculateTotal(items)).toBe(35)
    })

    it('应该处理空数组', () => {
      expect(calculateTotal([])).toBe(0)
    })
  })
})

6.2.3 异步测试

// test/api.test.ts
import { describe, it, expect, vi } from 'vitest'

// 模拟 API 函数
export async function fetchUser(id: string) {
  const response = await fetch(`/api/users/${id}`)
  if (!response.ok) {
    throw new Error('用户不存在')
  }
  return response.json()
}

describe('API 测试', () => {
  it('应该成功获取用户信息', async () => {
    // 模拟 fetch
    global.fetch = vi.fn().mockResolvedValue({
      ok: true,
      json: () => Promise.resolve({ id: '1', name: '张三' })
    })

    const user = await fetchUser('1')
    expect(user).toEqual({ id: '1', name: '张三' })
    expect(fetch).toHaveBeenCalledWith('/api/users/1')
  })

  it('应该处理错误情况', async () => {
    global.fetch = vi.fn().mockResolvedValue({
      ok: false
    })

    await expect(fetchUser('999')).rejects.toThrow('用户不存在')
  })
})

🧩 6.3 Vue 组件测试

6.3.1 基础组件测试

<!-- src/components/Counter.vue -->
<template>
  <div class="counter">
    <button @click="decrement" :disabled="count <= 0">-</button>
    <span class="count">{{ count }}</span>
    <button @click="increment">+</button>
    <button @click="reset">重置</button>
  </div>
</template>

<script setup lang="ts">
interface Props {
  initialValue?: number
  max?: number
  min?: number
}

const props = withDefaults(defineProps<Props>(), {
  initialValue: 0,
  max: Infinity,
  min: 0
})

const emit = defineEmits<{
  change: [value: number]
  reset: []
}>()

const count = ref(props.initialValue)

const increment = () => {
  if (count.value < props.max) {
    count.value++
    emit('change', count.value)
  }
}

const decrement = () => {
  if (count.value > props.min) {
    count.value--
    emit('change', count.value)
  }
}

const reset = () => {
  count.value = props.initialValue
  emit('reset')
}
</script>
// test/components/Counter.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from '@/components/Counter.vue'

describe('Counter 组件', () => {
  it('应该正确渲染初始值', () => {
    const wrapper = mount(Counter, {
      props: { initialValue: 5 }
    })
    
    expect(wrapper.find('.count').text()).toBe('5')
  })

  it('应该增加计数', async () => {
    const wrapper = mount(Counter)
    
    await wrapper.find('button:last-of-type').trigger('click')
    expect(wrapper.find('.count').text()).toBe('1')
  })

  it('应该减少计数', async () => {
    const wrapper = mount(Counter, {
      props: { initialValue: 5 }
    })
    
    await wrapper.find('button:first-of-type').trigger('click')
    expect(wrapper.find('.count').text()).toBe('4')
  })

  it('应该触发 change 事件', async () => {
    const wrapper = mount(Counter)
    
    await wrapper.find('button:last-of-type').trigger('click')
    expect(wrapper.emitted('change')).toBeTruthy()
    expect(wrapper.emitted('change')?.[0]).toEqual([1])
  })

  it('应该禁用减少按钮当计数为最小值', () => {
    const wrapper = mount(Counter, {
      props: { initialValue: 0, min: 0 }
    })
    
    const decrementBtn = wrapper.find('button:first-of-type')
    expect(decrementBtn.attributes('disabled')).toBeDefined()
  })

  it('应该重置计数', async () => {
    const wrapper = mount(Counter, {
      props: { initialValue: 10 }
    })
    
    // 先改变值
    await wrapper.find('button:last-of-type').trigger('click')
    expect(wrapper.find('.count').text()).toBe('11')
    
    // 重置
    await wrapper.find('button[class*="reset"]').trigger('click')
    expect(wrapper.find('.count').text()).toBe('10')
    expect(wrapper.emitted('reset')).toBeTruthy()
  })
})

6.3.2 复杂组件测试

<!-- 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"
        required
        :class="{ error: errors.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"
        required
        :class="{ error: errors.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"
        min="1"
        max="120"
        required
        :class="{ error: errors.age }"
      />
      <span v-if="errors.age" class="error-message">{{ errors.age }}</span>
    </div>

    <button type="submit" :disabled="isSubmitting">
      {{ isSubmitting ? '提交中...' : '提交' }}
    </button>
  </form>
</template>

<script setup lang="ts">
interface UserForm {
  name: string
  email: string
  age: number
}

interface Props {
  initialData?: Partial<UserForm>
}

const props = withDefaults(defineProps<Props>(), {
  initialData: () => ({})
})

const emit = defineEmits<{
  submit: [data: UserForm]
}>()

const form = reactive<UserForm>({
  name: '',
  email: '',
  age: 0,
  ...props.initialData
})

const errors = reactive<Partial<Record<keyof UserForm, string>>>({})
const isSubmitting = ref(false)

const validateForm = (): boolean => {
  Object.keys(errors).forEach(key => delete errors[key as keyof UserForm])

  if (!form.name.trim()) {
    errors.name = '姓名不能为空'
  }

  if (!form.email.trim()) {
    errors.email = '邮箱不能为空'
  } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
    errors.email = '邮箱格式不正确'
  }

  if (!form.age || form.age < 1 || form.age > 120) {
    errors.age = '年龄必须在1-120之间'
  }

  return Object.keys(errors).length === 0
}

const handleSubmit = async () => {
  if (!validateForm()) return

  isSubmitting.value = true
  try {
    emit('submit', { ...form })
  } finally {
    isSubmitting.value = false
  }
}
</script>
// test/components/UserForm.test.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import UserForm from '@/components/UserForm.vue'

describe('UserForm 组件', () => {
  it('应该正确渲染表单', () => {
    const wrapper = mount(UserForm)
    
    expect(wrapper.find('input[name="name"]').exists()).toBe(true)
    expect(wrapper.find('input[name="email"]').exists()).toBe(true)
    expect(wrapper.find('input[name="age"]').exists()).toBe(true)
    expect(wrapper.find('button[type="submit"]').exists()).toBe(true)
  })

  it('应该显示初始数据', () => {
    const initialData = {
      name: '张三',
      email: 'zhangsan@example.com',
      age: 25
    }
    
    const wrapper = mount(UserForm, {
      props: { initialData }
    })
    
    expect(wrapper.find('input[name="name"]').element.value).toBe('张三')
    expect(wrapper.find('input[name="email"]').element.value).toBe('zhangsan@example.com')
    expect(wrapper.find('input[name="age"]').element.value).toBe('25')
  })

  it('应该验证必填字段', async () => {
    const wrapper = mount(UserForm)
    
    await wrapper.find('form').trigger('submit')
    
    expect(wrapper.find('.error-message').text()).toContain('姓名不能为空')
  })

  it('应该验证邮箱格式', async () => {
    const wrapper = mount(UserForm)
    
    await wrapper.find('input[name="name"]').setValue('张三')
    await wrapper.find('input[name="email"]').setValue('invalid-email')
    await wrapper.find('input[name="age"]').setValue('25')
    await wrapper.find('form').trigger('submit')
    
    expect(wrapper.find('.error-message').text()).toContain('邮箱格式不正确')
  })

  it('应该验证年龄范围', async () => {
    const wrapper = mount(UserForm)
    
    await wrapper.find('input[name="name"]').setValue('张三')
    await wrapper.find('input[name="email"]').setValue('zhangsan@example.com')
    await wrapper.find('input[name="age"]').setValue('150')
    await wrapper.find('form').trigger('submit')
    
    expect(wrapper.find('.error-message').text()).toContain('年龄必须在1-120之间')
  })

  it('应该提交有效数据', async () => {
    const wrapper = mount(UserForm)
    
    await wrapper.find('input[name="name"]').setValue('张三')
    await wrapper.find('input[name="email"]').setValue('zhangsan@example.com')
    await wrapper.find('input[name="age"]').setValue('25')
    await wrapper.find('form').trigger('submit')
    
    expect(wrapper.emitted('submit')).toBeTruthy()
    expect(wrapper.emitted('submit')?.[0]).toEqual([{
      name: '张三',
      email: 'zhangsan@example.com',
      age: 25
    }])
  })

  it('应该在提交时禁用按钮', async () => {
    const wrapper = mount(UserForm)
    
    await wrapper.find('input[name="name"]').setValue('张三')
    await wrapper.find('input[name="email"]').setValue('zhangsan@example.com')
    await wrapper.find('input[name="age"]').setValue('25')
    
    const submitBtn = wrapper.find('button[type="submit"]')
    expect(submitBtn.attributes('disabled')).toBeUndefined()
    
    await wrapper.find('form').trigger('submit')
    expect(submitBtn.attributes('disabled')).toBeDefined()
  })
})

6.3.3 组合式函数测试

// src/composables/useCounter.ts
export function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  
  const increment = () => count.value++
  const decrement = () => count.value--
  const reset = () => count.value = initialValue
  
  return {
    count: readonly(count),
    increment,
    decrement,
    reset
  }
}
// test/composables/useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from '@/composables/useCounter'

describe('useCounter', () => {
  it('应该返回初始值', () => {
    const { count } = useCounter(5)
    expect(count.value).toBe(5)
  })

  it('应该增加计数', () => {
    const { count, increment } = useCounter()
    increment()
    expect(count.value).toBe(1)
  })

  it('应该减少计数', () => {
    const { count, decrement } = useCounter(5)
    decrement()
    expect(count.value).toBe(4)
  })

  it('应该重置计数', () => {
    const { count, increment, reset } = useCounter(10)
    increment()
    increment()
    expect(count.value).toBe(12)
    
    reset()
    expect(count.value).toBe(10)
  })
})

🎭 6.4 Playwright 端到端测试

6.4.1 环境配置

# 安装 Playwright
pnpm add -D @playwright/test
npx playwright install
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './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:3000',
    trace: 'on-first-retry',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
  ],
  webServer: {
    command: 'pnpm dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
})

6.4.2 基础 E2E 测试

// e2e/homepage.spec.ts
import { test, expect } from '@playwright/test'

test.describe('首页测试', () => {
  test('应该正确加载首页', async ({ page }) => {
    await page.goto('/')
    
    // 检查页面标题
    await expect(page).toHaveTitle(/Vue App/)
    
    // 检查关键元素
    await expect(page.locator('h1')).toBeVisible()
    await expect(page.locator('nav')).toBeVisible()
  })

  test('应该能够导航到关于页面', async ({ page }) => {
    await page.goto('/')
    
    // 点击导航链接
    await page.click('a[href="/about"]')
    
    // 验证页面跳转
    await expect(page).toHaveURL('/about')
    await expect(page.locator('h1')).toContainText('关于我们')
  })
})

6.4.3 表单交互测试

// e2e/contact.spec.ts
import { test, expect } from '@playwright/test'

test.describe('联系表单测试', () => {
  test('应该能够提交联系表单', async ({ page }) => {
    await page.goto('/contact')
    
    // 填写表单
    await page.fill('input[name="name"]', '张三')
    await page.fill('input[name="email"]', 'zhangsan@example.com')
    await page.fill('textarea[name="message"]', '这是一条测试消息')
    
    // 提交表单
    await page.click('button[type="submit"]')
    
    // 验证成功消息
    await expect(page.locator('.success-message')).toBeVisible()
    await expect(page.locator('.success-message')).toContainText('消息发送成功')
  })

  test('应该显示验证错误', async ({ page }) => {
    await page.goto('/contact')
    
    // 直接提交空表单
    await page.click('button[type="submit"]')
    
    // 验证错误消息
    await expect(page.locator('.error-message')).toBeVisible()
    await expect(page.locator('.error-message')).toContainText('请填写所有必填字段')
  })
})

6.4.4 用户认证流程测试

// e2e/auth.spec.ts
import { test, expect } from '@playwright/test'

test.describe('用户认证测试', () => {
  test('应该能够登录', async ({ page }) => {
    await page.goto('/login')
    
    // 填写登录表单
    await page.fill('input[name="email"]', 'test@example.com')
    await page.fill('input[name="password"]', 'password123')
    
    // 提交登录
    await page.click('button[type="submit"]')
    
    // 验证登录成功
    await expect(page).toHaveURL('/dashboard')
    await expect(page.locator('.user-menu')).toBeVisible()
  })

  test('应该能够登出', async ({ page }) => {
    // 先登录
    await page.goto('/login')
    await page.fill('input[name="email"]', 'test@example.com')
    await page.fill('input[name="password"]', 'password123')
    await page.click('button[type="submit"]')
    
    // 登出
    await page.click('.user-menu')
    await page.click('button:has-text("登出")')
    
    // 验证登出成功
    await expect(page).toHaveURL('/login')
  })

  test('应该保护需要认证的页面', async ({ page }) => {
    // 直接访问受保护的页面
    await page.goto('/dashboard')
    
    // 应该重定向到登录页
    await expect(page).toHaveURL('/login')
  })
})

🎨 6.5 视觉回归测试

6.5.1 配置 Chromatic

# 安装 Chromatic
pnpm add -D chromatic
// chromatic.config.js
module.exports = {
  projectToken: process.env.CHROMATIC_PROJECT_TOKEN,
  buildScriptName: 'build-storybook',
  storybookBuildDir: 'storybook-static',
  exitZeroOnChanges: true,
  exitOnceUploaded: true,
}

6.5.2 组件快照测试

// test/components/Button.spec.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Button from '@/components/Button.vue'

describe('Button 组件快照测试', () => {
  it('应该匹配默认按钮快照', () => {
    const wrapper = mount(Button, {
      slots: { default: '点击我' }
    })
    expect(wrapper.html()).toMatchSnapshot()
  })

  it('应该匹配主要按钮快照', () => {
    const wrapper = mount(Button, {
      props: { variant: 'primary' },
      slots: { default: '主要按钮' }
    })
    expect(wrapper.html()).toMatchSnapshot()
  })

  it('应该匹配禁用按钮快照', () => {
    const wrapper = mount(Button, {
      props: { disabled: true },
      slots: { default: '禁用按钮' }
    })
    expect(wrapper.html()).toMatchSnapshot()
  })
})

🚀 6.6 测试驱动开发 (TDD)

6.6.1 TDD 流程

  1. Red:写一个失败的测试
  2. Green:写最少的代码让测试通过
  3. Refactor:重构代码,保持测试通过

6.6.2 TDD 示例

// test/utils/calculator.test.ts
import { describe, it, expect } from 'vitest'
import { Calculator } from '@/utils/calculator'

describe('Calculator TDD 示例', () => {
  let calculator: Calculator

  beforeEach(() => {
    calculator = new Calculator()
  })

  // Red: 写失败的测试
  it('应该能够相加两个数字', () => {
    expect(calculator.add(2, 3)).toBe(5)
  })

  it('应该能够相减两个数字', () => {
    expect(calculator.subtract(5, 3)).toBe(2)
  })

  it('应该能够相乘两个数字', () => {
    expect(calculator.multiply(4, 3)).toBe(12)
  })

  it('应该能够相除两个数字', () => {
    expect(calculator.divide(10, 2)).toBe(5)
  })

  it('应该处理除零错误', () => {
    expect(() => calculator.divide(10, 0)).toThrow('不能除以零')
  })
})
// src/utils/calculator.ts
export class Calculator {
  add(a: number, b: number): number {
    return a + b
  }

  subtract(a: number, b: number): number {
    return a - b
  }

  multiply(a: number, b: number): number {
    return a * b
  }

  divide(a: number, b: number): number {
    if (b === 0) {
      throw new Error('不能除以零')
    }
    return a / b
  }
}

📊 6.7 测试覆盖率

6.7.1 配置覆盖率

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

6.7.2 覆盖率报告

# 生成覆盖率报告
pnpm test:coverage

# 查看 HTML 报告
open coverage/index.html

🎯 6.8 测试最佳实践

6.8.1 测试命名

// ✅ 好的测试命名
describe('UserService', () => {
  describe('createUser', () => {
    it('应该创建新用户并返回用户信息', () => {})
    it('应该在邮箱已存在时抛出错误', () => {})
    it('应该验证邮箱格式', () => {})
  })
})

// ❌ 不好的测试命名
describe('UserService', () => {
  it('test1', () => {})
  it('should work', () => {})
})

6.8.2 测试结构

// AAA 模式:Arrange, Act, Assert
describe('用户登录', () => {
  it('应该成功登录有效用户', async () => {
    // Arrange: 准备测试数据
    const user = { email: 'test@example.com', password: 'password123' }
    const mockApi = vi.fn().mockResolvedValue({ token: 'abc123' })
    
    // Act: 执行被测试的操作
    const result = await loginUser(user, mockApi)
    
    // Assert: 验证结果
    expect(result.token).toBe('abc123')
    expect(mockApi).toHaveBeenCalledWith('/login', user)
  })
})

6.8.3 模拟和存根

// 模拟外部依赖
describe('用户服务', () => {
  it('应该获取用户列表', async () => {
    // 模拟 API 响应
    const mockUsers = [
      { id: 1, name: '张三' },
      { id: 2, name: '李四' }
    ]
    
    global.fetch = vi.fn().mockResolvedValue({
      ok: true,
      json: () => Promise.resolve(mockUsers)
    })
    
    const users = await fetchUsers()
    expect(users).toEqual(mockUsers)
  })
})

🎯 本章总结

通过本章学习,你掌握了:

  1. 测试基础:测试金字塔、测试类型
  2. Vitest 单元测试:环境配置、基础测试、异步测试
  3. Vue 组件测试:组件测试、组合式函数测试
  4. Playwright E2E 测试:环境配置、用户流程测试
  5. 视觉回归测试:快照测试、Chromatic 集成
  6. 测试驱动开发:TDD 流程和实践
  7. 测试最佳实践:命名规范、测试结构、模拟技巧

📝 练习题

  1. 为现有的工具函数编写单元测试,确保覆盖率超过 80%
  2. 为 Vue 组件编写完整的测试套件,包括边界情况
  3. 使用 Playwright 编写用户注册和登录的 E2E 测试
  4. 实现一个计算器组件,使用 TDD 方法开发
  5. 配置测试覆盖率报告,并优化测试覆盖率

🔗 相关资源

  • Vitest 官方文档
  • Vue Test Utils 文档
  • Playwright 官方文档
  • Testing Library 文档
  • Jest 文档
Prev
第5章:工程化与性能
Next
第7章:CSS 进阶专题