{T}

测试概述

理解 Vue 3 应用的测试策略、测试类型划分以及测试框架的选择。

为什么需要测试

测试在软件开发生命周期中扮演关键角色:

测试价值描述
质量保障及早发现缺陷,降低生产环境风险
重构信心测试覆盖提供安全网,支持代码重构
文档作用测试用例即活文档,展示预期行为
设计改进编写测试促使更好的模块化设计

测试金字塔

测试金字塔描述了测试的层次结构:

code
            ▲
           /E2E\          数量少,运行慢,成本高
          /------\
         / 组件测试 \       数量适中,运行较快
        /----------\
       /   单元测试  \     数量多,运行快,成本低
      /--------------\

各层测试对比

特性单元测试组件测试E2E 测试
测试范围函数/模块Vue 组件完整应用
运行速度毫秒级秒级分钟级
维护成本
调试难度简单中等较难
测试可靠性较低
覆盖范围代码逻辑组件行为用户流程

一、单元测试

单元测试针对应用中最小可测试单元进行验证。

适用场景

  • 工具函数(utils/helpers)
  • 业务逻辑函数
  • 组合式函数(Composables)
  • Pinia Store 模块

框架选择

Vitest(推荐)

Vitest 是 Vite 原生的测试框架,与 Vite 无缝集成:

bash
# 安装
npm install -D vitest @vue/test-utils jsdom

优势:

  • ⚡ 与 Vite 共享配置,极速启动
  • 🔧 兼容 Jest API,迁移成本低
  • 📦 原生支持 ESM 和 TypeScript
  • 🎯 开箱即用的 Vue 组件测试支持
  • 🔄 HMR 支持,测试热更新

Vitest 配置示例:

typescript
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  test: {
    globals: true,
    environment: 'jsdom',
    include: ['tests/**/*.{test,spec}.{js,ts}'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html']
    }
  }
})

Jest

Jest 是成熟的测试框架,需要额外配置:

bash
# 安装
npm install -D jest @vue/vue3-jest babel-jest

优势:

  • 成熟稳定,生态丰富
  • 完善的 Mock 功能
  • 快照测试支持

框架对比

特性VitestJest
启动速度极快较慢
Vite 集成原生需配置
配置复杂度
ESM 支持原生需配置
HMR支持不支持
迁移成本-低(API 兼容)

二、组件测试

组件测试验证 Vue 组件的渲染输出、用户交互和状态管理。

适用场景

  • UI 组件渲染测试
  • 用户交互测试(点击、输入)
  • Props 和事件测试
  • 插槽内容测试
  • 组件生命周期测试

框架选择

@vue/test-utils(官方推荐)

bash
npm install -D @vue/test-utils jsdom

特点:

  • Vue 官方维护
  • 提供底层 API
  • 完整的组件控制能力
  • 支持 Vue 3 所有特性

示例:

typescript
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'

test('计数器递增', async () => {
  const wrapper = mount(Counter)
  
  expect(wrapper.text()).toContain('0')
  
  await wrapper.find('button').trigger('click')
  
  expect(wrapper.text()).toContain('1')
})

Vue Testing Library

bash
npm install -D @testing-library/vue

测试原则:

  • 测试用户可见行为
  • 避免依赖实现细节
  • 重视可访问性

选择建议

场景推荐框架
新项目 / 需要底层控制@vue/test-utils
以用户行为为中心Vue Testing Library
已有项目迁移保持原框架一致性

三、端到端测试(E2E)

端到端测试模拟真实用户行为,验证完整的应用流程。

适用场景

  • 关键业务流程(注册、登录、支付)
  • 跨页面操作流程
  • 表单提交和数据持久化
  • 第三方集成验证

框架选择

Cypress(推荐入门)

bash
npm install -D cypress
npx cypress open

优势:

  • 实时重载和调试
  • 时间旅行功能
  • 自动等待机制
  • 网络请求控制
  • 优秀的开发者体验

Playwright(推荐生产)

bash
npm install -D @playwright/test
npx playwright install

优势:

  • 多浏览器支持(Chromium、Firefox、WebKit)
  • 强大的选择器引擎
  • 自动等待和重试
  • 原生支持并行测试

E2E 框架对比

特性CypressPlaywright
学习曲线
调试体验优秀良好
跨浏览器Chromium系全支持
执行速度
并行测试需付费原生支持
CI 集成简单简单

四、测试策略建议

测试覆盖原则

code
┌─────────────────────────────────────────────────────┐
│                    测试策略                          │
├─────────────────────────────────────────────────────┤
│  1. 核心业务逻辑 → 100% 单元测试覆盖                 │
│  2. 关键组件 → 组件测试 + 快照测试                   │
│  3. 用户关键路径 → E2E 测试                          │
│  4. 边缘情况 → 按需补充                              │
└─────────────────────────────────────────────────────┘

测试优先级

优先级测试类型测试内容
P0单元测试核心算法、支付逻辑、权限验证
P0E2E 测试注册/登录、支付流程、核心业务
P1组件测试表单组件、弹窗组件、列表组件
P1单元测试工具函数、数据转换
P2组件测试展示型组件
P3快照测试UI 组件外观

Vue 3 测试特点

与 Vue 2 相比,Vue 3 在测试方面有以下优势:

特性Vue 2Vue 3
组合式函数测试较难简单直接
响应式测试需要 Vue Test Utils可独立测试
TypeScript支持有限完美支持
Tree-shaking不支持支持

组合式函数测试示例:

typescript
// useCounter.test.ts
import { expect, test } from 'vitest'
import { useCounter } from './useCounter'

test('useCounter 组合式函数', () => {
  // 直接测试,无需挂载组件
  const { count, increment, decrement } = useCounter(5)
  
  expect(count.value).toBe(5)
  increment()
  expect(count.value).toBe(6)
  decrement()
  expect(count.value).toBe(5)
})

五、常见问题

Q1: 单元测试和组件测试的区别?

单元测试关注独立的函数或模块,不涉及 DOM 渲染;组件测试需要挂载 Vue 组件到 DOM,验证渲染和交互行为。

Q2: 测试覆盖率多少合适?

建议目标:

  • 整体覆盖率:70-80%
  • 核心业务模块:90%+
  • E2E 覆盖核心用户流程即可

Q3: 如何选择 E2E 框架?

  • Cypress:入门简单,调试体验好,适合小中型项目
  • Playwright:跨浏览器支持好,适合大型项目和企业级应用

Q4: Vitest 和 Jest 如何选择?

  • 新项目:推荐 Vitest,与 Vite 无缝集成
  • 现有 Jest 项目:可继续使用 Jest,或逐步迁移到 Vitest
  • 非 Vite 项目:Jest 仍是好选择

相关资源

下一步


单元测试

使用 Vitest 进行 Vue 3 应用的单元测试。

安装与配置

安装依赖

bash
# 安装 Vitest 和测试工具
npm install -D vitest @vue/test-utils jsdom @vitest/coverage-v8

# 如果使用 TypeScript
npm install -D @vitest/expect

配置文件

typescript
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  test: {
    // 启用全局 API(describe, test, expect 等)
    globals: true,
    // 使用 jsdom 模拟浏览器环境
    environment: 'jsdom',
    // 测试文件匹配模式
    include: ['**/*.{test,spec}.{js,ts}'],
    // 排除目录
    exclude: ['node_modules', 'dist'],
    // 覆盖率配置
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'tests/',
        '**/*.d.ts',
        '**/*.config.*'
      ]
    }
  }
})

package.json 脚本

json
{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest run --coverage"
  }
}

基本测试

测试工具函数

typescript
// utils/math.ts
export function add(a: number, b: number): number {
  return a + b
}

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

export function divide(a: number, b: number): number {
  if (b === 0) {
    throw new Error('不能除以零')
  }
  return a / b
}
typescript
// utils/math.test.ts
import { describe, it, expect } from 'vitest'
import { add, multiply, divide } from './math'

describe('数学工具函数', () => {
  describe('add', () => {
    it('应该正确相加两个正数', () => {
      expect(add(1, 2)).toBe(3)
    })

    it('应该正确处理负数', () => {
      expect(add(-1, -2)).toBe(-3)
    })

    it('应该正确处理零', () => {
      expect(add(0, 0)).toBe(0)
    })
  })

  describe('multiply', () => {
    it('应该正确相乘', () => {
      expect(multiply(3, 4)).toBe(12)
    })
  })

  describe('divide', () => {
    it('应该正确相除', () => {
      expect(divide(10, 2)).toBe(5)
    })

    it('除以零时应该抛出错误', () => {
      expect(() => divide(10, 0)).toThrow('不能除以零')
    })
  })
})

测试组合式函数

Vue 3 的组合式函数可以独立测试,无需挂载组件:

typescript
// composables/useCounter.ts
import { ref, computed } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  const double = computed(() => count.value * 2)

  function increment() {
    count.value++
  }

  function decrement() {
    count.value--
  }

  function reset() {
    count.value = initialValue
  }

  return {
    count,
    double,
    increment,
    decrement,
    reset
  }
}
typescript
// composables/useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'

describe('useCounter', () => {
  it('应该使用初始值初始化', () => {
    const { count, double } = useCounter(5)
    
    expect(count.value).toBe(5)
    expect(double.value).toBe(10)
  })

  it('应该正确递增', () => {
    const { count, increment } = useCounter(0)
    
    increment()
    expect(count.value).toBe(1)
    
    increment()
    expect(count.value).toBe(2)
  })

  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)
  })
})

Mock 功能

函数 Mock

typescript
// utils/api.ts
export async function fetchUser(id: number) {
  const response = await fetch(`/api/users/${id}`)
  return response.json()
}
typescript
// utils/api.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { fetchUser } from './api'

describe('API 函数', () => {
  beforeEach(() => {
    // 每个测试前重置所有 mock
    vi.resetAllMocks()
  })

  afterEach(() => {
    // 每个 test 后恢复所有 mock
    vi.restoreAllMocks()
  })

  it('应该获取用户数据', async () => {
    // Mock fetch
    global.fetch = vi.fn().mockResolvedValue({
      json: () => Promise.resolve({ id: 1, name: 'Vue' })
    })

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

  it('应该处理错误', async () => {
    global.fetch = vi.fn().mockRejectedValue(new Error('Network Error'))

    await expect(fetchUser(1)).rejects.toThrow('Network Error')
  })
})

模块 Mock

typescript
// utils/storage.ts
export function saveToStorage(key: string, value: string) {
  localStorage.setItem(key, value)
}

export function getFromStorage(key: string) {
  return localStorage.getItem(key)
}
typescript
// utils/storage.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { saveToStorage, getFromStorage } from './storage'

describe('Storage 工具', () => {
  beforeEach(() => {
    // Mock localStorage
    const localStorageMock = {
      store: {} as Record<string, string>,
      setItem(key: string, value: string) {
        this.store[key] = value
      },
      getItem(key: string) {
        return this.store[key] || null
      },
      clear() {
        this.store = {}
      }
    }
    
    Object.defineProperty(global, 'localStorage', {
      value: localStorageMock
    })
  })

  it('应该保存数据到 localStorage', () => {
    saveToStorage('token', 'abc123')
    expect(localStorage.getItem('token')).toBe('abc123')
  })

  it('应该从 localStorage 获取数据', () => {
    localStorage.setItem('user', 'Vue')
    expect(getFromStorage('user')).toBe('Vue')
  })
})

定时器 Mock

typescript
// utils/timer.ts
export function debounce<T extends (...args: unknown[]) => unknown>(
  fn: T,
  delay: number
): (...args: Parameters<T>) => void {
  let timeoutId: ReturnType<typeof setTimeout>
  
  return function (this: unknown, ...args: Parameters<T>) {
    clearTimeout(timeoutId)
    timeoutId = setTimeout(() => fn.apply(this, args), delay)
  }
}
typescript
// utils/timer.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { debounce } from './timer'

describe('debounce', () => {
  beforeEach(() => {
    vi.useFakeTimers()
  })

  afterEach(() => {
    vi.restoreAllTimers()
  })

  it('应该延迟执行函数', () => {
    const fn = vi.fn()
    const debouncedFn = debounce(fn, 100)

    debouncedFn()
    expect(fn).not.toHaveBeenCalled()

    vi.advanceTimersByTime(50)
    expect(fn).not.toHaveBeenCalled()

    vi.advanceTimersByTime(50)
    expect(fn).toHaveBeenCalledTimes(1)
  })

  it('应该只执行最后一次调用', () => {
    const fn = vi.fn()
    const debouncedFn = debounce(fn, 100)

    debouncedFn('a')
    debouncedFn('b')
    debouncedFn('c')

    vi.advanceTimersByTime(100)
    
    expect(fn).toHaveBeenCalledTimes(1)
    expect(fn).toHaveBeenCalledWith('c')
  })
})

快照测试

快照测试用于捕获组件或数据的预期输出:

typescript
// utils/formatter.ts
export function formatUser(user: { name: string; email: string; age: number }) {
  return {
    displayName: user.name.toUpperCase(),
    contact: user.email.toLowerCase(),
    category: user.age < 18 ? 'minor' : 'adult',
    formatted: `${user.name} <${user.email}>`
  }
}
typescript
// utils/formatter.test.ts
import { describe, it, expect } from 'vitest'
import { formatUser } from './formatter'

describe('formatUser', () => {
  it('应该正确格式化成年用户', () => {
    const result = formatUser({
      name: 'Vue',
      email: 'VUE@Example.COM',
      age: 25
    })
    
    expect(result).toMatchSnapshot()
  })

  it('应该正确格式化未成年用户', () => {
    const result = formatUser({
      name: 'Vue',
      email: 'vue@example.com',
      age: 15
    })
    
    expect(result).toMatchSnapshot()
  })

  // 行内快照
  it('应该返回正确的结构', () => {
    const result = formatUser({
      name: 'Test',
      email: 'test@test.com',
      age: 20
    })
    
    expect(result).toMatchInlineSnapshot(`
      {
        "category": "adult",
        "contact": "test@test.com",
        "displayName": "TEST",
        "formatted": "Test <test@test.com>",
      }
    `)
  })
})

更新快照

bash
# 更新所有快照
npx vitest run --update

# 交互式更新快照
npx vitest watch --update

测试覆盖率

配置覆盖率

typescript
// vite.config.ts
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      // 报告格式
      reporter: ['text', 'json', 'html', 'lcov'],
      // 覆盖率阈值
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 70,
        statements: 80
      },
      // 包含文件
      include: ['src/**/*.{js,ts,vue}'],
      // 排除文件
      exclude: [
        'src/main.ts',
        'src/**/*.d.ts',
        'src/**/*.spec.ts',
        'src/**/*.test.ts'
      ]
    }
  }
})

运行覆盖率报告

bash
# 生成覆盖率报告
npm run test:coverage

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

覆盖率指标说明

指标说明
Statements语句覆盖率 - 执行的语句比例
Branches分支覆盖率 - 执行的条件分支比例
Functions函数覆盖率 - 调用的函数比例
Lines行覆盖率 - 执行的代码行比例

测试 Pinia Store

typescript
// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useUserStore = defineStore('user', () => {
  const user = ref<{ id: number; name: string } | null>(null)
  const isLoggedIn = computed(() => !!user.value)

  function login(userData: { id: number; name: string }) {
    user.value = userData
  }

  function logout() {
    user.value = null
  }

  return { user, isLoggedIn, login, logout }
})
typescript
// stores/user.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useUserStore } from './user'

describe('User Store', () => {
  beforeEach(() => {
    // 每个测试创建新的 Pinia 实例
    setActivePinia(createPinia())
  })

  it('初始状态应该为空', () => {
    const store = useUserStore()
    
    expect(store.user).toBeNull()
    expect(store.isLoggedIn).toBe(false)
  })

  it('login 应该设置用户', () => {
    const store = useUserStore()
    
    store.login({ id: 1, name: 'Vue' })
    
    expect(store.user).toEqual({ id: 1, name: 'Vue' })
    expect(store.isLoggedIn).toBe(true)
  })

  it('logout 应该清除用户', () => {
    const store = useUserStore()
    
    store.login({ id: 1, name: 'Vue' })
    store.logout()
    
    expect(store.user).toBeNull()
    expect(store.isLoggedIn).toBe(false)
  })
})

高级技巧

参数化测试

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

describe('参数化测试示例', () => {
  it.each([
    [1, 2, 3],
    [2, 3, 5],
    [10, 20, 30]
  ])('add(%i, %i) 应该等于 %i', (a, b, expected) => {
    expect(add(a, b)).toBe(expected)
  })

  it.each([
    { input: 'hello', expected: 'HELLO' },
    { input: 'Vue', expected: 'VUE' },
    { input: '', expected: '' }
  ])('toUpperCase($input) = $expected', ({ input, expected }) => {
    expect(input.toUpperCase()).toBe(expected)
  })
})

测试异步代码

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

describe('异步测试', () => {
  it('应该等待 Promise 完成', async () => {
    const result = await Promise.resolve('success')
    expect(result).toBe('success')
  })

  it('应该使用 resolves/rejects', async () => {
    await expect(Promise.resolve('success')).resolves.toBe('success')
    await expect(Promise.reject(new Error('failed'))).rejects.toThrow('failed')
  })

  it('应该处理回调', () => {
    return new Promise<void>((resolve) => {
      setTimeout(() => {
        expect(true).toBe(true)
        resolve()
      }, 100)
    })
  })
})

测试环境变量

typescript
// utils/config.ts
export function getApiUrl() {
  return import.meta.env.VITE_API_URL || 'http://localhost:3000'
}

export function isProduction() {
  return import.meta.env.PROD
}
typescript
// utils/config.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'

describe('配置工具', () => {
  const originalEnv = { ...import.meta.env }

  beforeEach(() => {
    vi.stubEnv('VITE_API_URL', 'https://api.example.com')
  })

  afterEach(() => {
    vi.unstubAllEnvs()
  })

  it('应该获取 API URL', () => {
    const { getApiUrl } = await import('./config')
    expect(getApiUrl()).toBe('https://api.example.com')
  })
})

常见问题

Q1: 如何测试 DOM 相关的代码?

确保配置了 environment: 'jsdom'

typescript
// vite.config.ts
export default defineConfig({
  test: {
    environment: 'jsdom'
  }
})

Q2: 测试中如何使用 setup 文件?

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

// 全局 mock
vi.stubGlobal('IntersectionObserver', vi.fn())

// 全局组件配置
config.global.stubs = {}

// vite.config.ts
export default defineConfig({
  test: {
    setupFiles: ['./tests/setup.ts']
  }
})

Q3: 如何跳过某些测试?

typescript
// 跳过单个测试
it.skip('这个测试暂时跳过', () => {
  // ...
})

// 跳过整个 describe
describe.skip('这个测试套件暂时跳过', () => {
  // ...
})

// 只运行特定测试
it.only('只运行这个测试', () => {
  // ...
})

Q4: 测试运行很慢怎么办?

bash
# 并行运行测试
npx vitest run --threads

# 只运行特定文件
npx vitest run math.test.ts

# 使用测试隔离
npx vitest run --isolate=false

Q5: 如何调试测试?

bash
# 使用 VS Code 调试
# 在 launch.json 中添加配置
{
  "type": "node",
  "request": "launch",
  "name": "Debug Tests",
  "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/vitest",
  "runtimeArgs": ["run"],
  "console": "integratedTerminal"
}

相关资源

下一步