Vue3 单元测试实战:从组合式函数到组件

37 阅读10分钟

前言

在软件开发中,测试常常被视为“有时间再做”的奢侈品。然而,当项目规模扩大、团队人员变动、需求频繁变更时,没有测试的代码库会逐渐变成难以维护的"遗留系统"。

为什么需要测试?

一个没有测试的项目会怎样?

场景1:重构时的不安全感

当我们改完某个模块的代码后,怎么知道有没有破坏原有功能呢?此时我们只能手动点击测试,但只要漏掉一个边界情况就出 Bug。

场景2:新人接手代码

当团队来了新人后,第一件事是需要熟悉项目的代码。但如果没有测试,就没有相关的文档;想要理解一个函数的边界情况就会很困难。

场景3:上线前的焦虑

每次发布都要花好几个小时手工测试一遍。

测试的投资回报率

阶段没有测试有测试收益
开发阶段手动测试每个功能保存时自动运行节省 30% 时间
重构阶段不敢改代码改完运行测试重构效率提升 200%
Code Reviewreviewer 手动验证看测试用例理解时间缩短 50%
上线阶段每次提心吊胆测试通过就上线信心 100%

测试策略金字塔

        /\
       /  \      E2E 测试 (少量)
      /    \     模拟真实用户操作,成本高
     /------\
    /        \   组件测试 (适量)
   /          \  测试组件交互和渲染
  /------------\
 /              \ 单元测试 (大量)
/                \ 测试函数和组合式函数,速度快

原则:底层测试越多,上层测试越少
单元测试:60-70%
组件测试:20-30%
E2E 测试:5-10%

Vitest 快速上手

为什么选择 Vitest?

Vitest 可以与 Vite 的无缝集成,同一套配置、同一套插件、同一套别名。

Jest 的痛点

  • 需要配置 babel-jest、vue-jest、jest-serializer-vue
  • 与 Vite 的别名、插件不共享
  • 速度慢,尤其是冷启动

Vitest 的优势

  • 与 Vite 共享配置,零配置迁移
  • 多线程并发执行,速度快
  • 支持 ES Module 开箱即用
  • 与 Jest 几乎相同的 API

安装 Vitest

# 安装 Vitest
npm install --save-dev vitest

# 安装 Vue 测试工具
npm install --save-dev @vue/test-utils

# 安装 jsdom(浏览器环境模拟)
npm install --save-dev jsdom

Vite 配置集成

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

export default defineConfig({
  plugins: [vue()],
  test: {
    globals: true,  // 启用全局 API(describe, it, expect)
    environment: 'jsdom',  // 模拟浏览器环境
    include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'],  // 测试文件匹配模式
    coverage: {  // 测试覆盖率配置
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      include: ['src/**/*.{js,ts,vue}'],
      exclude: ['src/**/*.test.ts', 'src/**/*.spec.ts']
    },
    testTimeout: 5000,  // 测试超时时间
    setupFiles: ['./test/setup.ts']  // 全局 setup 文件
  }
})

添加测试脚本

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

第一个测试

假设我们有这样一个函数:

// src/utils/math.js
export function add(a, b) {
  return a + b
}

其对应的测试:

// src/utils/math.test.js
import { describe, it, expect } from 'vitest'
import { add } from './math'

describe('math.js', () => {
  it('1 + 1 应该等于 2', () => {
    expect(add(1, 1)).toBe(2)
  })
  
  it('负数相加', () => {
    expect(add(-1, -2)).toBe(-3)
  })
})

测试组合式函数

最简单的组合式函数

我们先看一个简单的组合式函数:

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

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  
  const increment = () => count.value++
  const decrement = () => count.value--
  const reset = () => count.value = initialValue
  
  return { count, increment, decrement, reset }
}

其对应的测试:

// composables/__tests__/useCounter.spec.js
import { describe, it, expect } from 'vitest'
import { useCounter } from '../useCounter'

describe('useCounter', () => {
  it('初始值应该是0', () => {
    const { count } = useCounter()
    expect(count.value).toBe(0)
  })
  
  it('可以设置初始值', () => {
    const { count } = useCounter(10)
    expect(count.value).toBe(10)
  })
  
  it('增加1', () => {
    const { count, increment } = useCounter(5)
    increment()
    expect(count.value).toBe(6)
  })
  
  it('减少1', () => {
    const { count, decrement } = useCounter(5)
    decrement()
    expect(count.value).toBe(4)
  })
  
  it('重置', () => {
    const { count, increment, reset } = useCounter(5)
    increment()
    increment()
    expect(count.value).toBe(7)
    reset()
    expect(count.value).toBe(5)
  })
})

带 computed 的组合式函数

我们再来看一个带 computed 的组合式函数:

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

export function useDouble(initialValue = 0) {
  const value = ref(initialValue)
  const double = computed(() => value.value * 2)
  
  const setValue = (newValue) => value.value = newValue
  
  return { value, double, setValue }
}

其对应的测试:

// composables/__tests__/useDouble.spec.js
import { describe, it, expect } from 'vitest'
import { useDouble } from '../useDouble'

describe('useDouble', () => {
  it('double 应该是 value 的两倍', () => {
    const { value, double } = useDouble(3)
    expect(double.value).toBe(6)
  })
  
  it('value 变化时 double 也跟着变', () => {
    const { value, double, setValue } = useDouble(3)
    setValue(5)
    expect(double.value).toBe(10)
    
    value.value = 7
    expect(double.value).toBe(14)
  })
})

带生命周期的组合式函数

我们再来看一个带生命周期的组合式函数:

// composables/useWindowWidth.js
import { ref, onMounted, onUnmounted } from 'vue'

export function useWindowWidth() {
  const width = ref(window.innerWidth)
  
  const updateWidth = () => {
    width.value = window.innerWidth
  }
  
  onMounted(() => {
    window.addEventListener('resize', updateWidth)
  })
  
  onUnmounted(() => {
    window.removeEventListener('resize', updateWidth)
  })
  
  return { width }
}

其对应的测试:

// composables/__tests__/useWindowWidth.spec.js
import { describe, it, expect, vi } from 'vitest'
import { useWindowWidth } from '../useWindowWidth'

// 辅助函数:让生命周期钩子执行
function withSetup(composable) {
  let result
  
  const app = createApp({
    setup() {
      result = composable()
      return () => {}
    }
  })
  
  app.mount(document.createElement('div'))
  
  return [result, app]
}

describe('useWindowWidth', () => {
  it('初始宽度是当前窗口宽度', () => {
    window.innerWidth = 1024
    const [result, app] = withSetup(() => useWindowWidth())
    expect(result.width.value).toBe(1024)
    app.unmount()
  })
  
  it('窗口大小变化时更新宽度', () => {
    window.innerWidth = 1024
    const [result, app] = withSetup(() => useWindowWidth())
    
    // 模拟窗口变化
    window.innerWidth = 800
    window.dispatchEvent(new Event('resize'))
    
    expect(result.width.value).toBe(800)
    app.unmount()
  })
  
  it('组件卸载时移除监听器', () => {
    const removeSpy = vi.spyOn(window, 'removeEventListener')
    
    const [result, app] = withSetup(() => useWindowWidth())
    app.unmount()
    
    expect(removeSpy).toHaveBeenCalledWith('resize', expect.any(Function))
  })
})

测试组件

安装 Vue Test Utils

npm install --save-dev @vue/test-utils

最简单的组件

我们来看一个最简单的组件:

<!-- Counter.vue -->
<template>
  <div>
    <span class="count">{{ count }}</span>
    <button @click="increment">增加</button>
    <button @click="decrement">减少</button>
  </div>
</template>

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

const count = ref(0)

const increment = () => count.value++
const decrement = () => count.value--
</script>

其对应的测试:

// __tests__/Counter.spec.js
import { mount } from '@vue/test-utils'
import Counter from '../Counter.vue'

describe('Counter', () => {
  it('初始显示 0', () => {
    const wrapper = mount(Counter)
    expect(wrapper.find('.count').text()).toBe('0')
  })
  
  it('点击增加按钮后变成 1', async () => {
    const wrapper = mount(Counter)
    await wrapper.find('button:first-child').trigger('click')
    expect(wrapper.find('.count').text()).toBe('1')
  })
  
  it('点击减少按钮后变成 -1', async () => {
    const wrapper = mount(Counter)
    await wrapper.find('button:last-child').trigger('click')
    expect(wrapper.find('.count').text()).toBe('-1')
  })
})

带 Props 的组件

我们再来看一个带 Props 的组件:

<!-- Greeting.vue -->
<template>
  <div>
    <h1>Hello, {{ name }}!</h1>
    <p v-if="showMessage">欢迎使用我们的应用</p>
  </div>
</template>

<script setup>
defineProps({
  name: String,
  showMessage: Boolean
})
</script>

其对应的测试:

// __tests__/Greeting.spec.js
import { mount } from '@vue/test-utils'
import Greeting from '../Greeting.vue'

describe('Greeting', () => {
  it('显示名字', () => {
    const wrapper = mount(Greeting, {
      props: { name: '张三' }
    })
    expect(wrapper.text()).toContain('Hello, 张三!')
  })
  
  it('showMessage 为 true 时显示欢迎语', () => {
    const wrapper = mount(Greeting, {
      props: { name: '张三', showMessage: true }
    })
    expect(wrapper.text()).toContain('欢迎使用我们的应用')
  })
  
  it('showMessage 为 false 时不显示欢迎语', () => {
    const wrapper = mount(Greeting, {
      props: { name: '张三', showMessage: false }
    })
    expect(wrapper.text()).not.toContain('欢迎使用我们的应用')
  })
})

带事件的组件

我们再来看一个带事件的组件:

<!-- SubmitButton.vue -->
<template>
  <button @click="handleClick" :disabled="disabled">
    {{ text }}
  </button>
</template>

<script setup>
const props = defineProps({
  text: String,
  disabled: Boolean
})

const emit = defineEmits(['submit'])

const handleClick = () => {
  emit('submit', 'button clicked')
}
</script>

其对应的测试:

// __tests__/SubmitButton.spec.js
import { mount } from '@vue/test-utils'
import SubmitButton from '../SubmitButton.vue'

describe('SubmitButton', () => {
  it('点击时触发 submit 事件', async () => {
    const wrapper = mount(SubmitButton, {
      props: { text: '提交' }
    })
    
    await wrapper.trigger('click')
    
    // 检查事件是否触发
    expect(wrapper.emitted()).toHaveProperty('submit')
    
    // 检查事件参数
    expect(wrapper.emitted('submit')[0]).toEqual(['button clicked'])
  })
  
  it('禁用时点击不触发事件', async () => {
    const wrapper = mount(SubmitButton, {
      props: { text: '提交', disabled: true }
    })
    
    await wrapper.trigger('click')
    
    expect(wrapper.emitted('submit')).toBeUndefined()
  })
})

Mock 外部依赖

为什么要 Mock?

真实开发中,组件往往依赖:

  • API 请求
  • 组合式函数
  • 第三方库

测试时我们也许不能发送真实请求给后端,因此需要使用 Mock 模拟真实数据。

Mock 组合式函数

我们先来看一个 Mock 组合式函数:

<!-- UserProfile.vue -->
<template>
  <div>
    <div v-if="loading">加载中...</div>
    <div v-else-if="error">错误: {{ error.message }}</div>
    <div v-else>
      <h2>{{ user.name }}</h2>
      <p>{{ user.email }}</p>
    </div>
  </div>
</template>

<script setup>
import { useUser } from '@/composables/useUser'

const props = defineProps({ userId: Number })
const { user, loading, error } = useUser(props.userId)
</script>

其对应的测试:

// __tests__/UserProfile.spec.js
import { mount } from '@vue/test-utils'
import { vi } from 'vitest'

// 先 Mock 模块
vi.mock('@/composables/useUser')

// 再导入(Mock 后的版本)
import { useUser } from '@/composables/useUser'
import UserProfile from '../UserProfile.vue'

describe('UserProfile', () => {
  it('加载中显示 loading', () => {
    // 设置 Mock 返回值
    useUser.mockReturnValue({
      user: ref(null),
      loading: ref(true),
      error: ref(null)
    })
    
    const wrapper = mount(UserProfile, {
      props: { userId: 1 }
    })
    
    expect(wrapper.text()).toContain('加载中...')
  })
  
  it('加载成功显示用户信息', () => {
    useUser.mockReturnValue({
      user: ref({ name: '张三', email: 'zhangsan@example.com' }),
      loading: ref(false),
      error: ref(null)
    })
    
    const wrapper = mount(UserProfile, {
      props: { userId: 1 }
    })
    
    expect(wrapper.text()).toContain('张三')
    expect(wrapper.text()).toContain('zhangsan@example.com')
  })
  
  it('加载失败显示错误', () => {
    useUser.mockReturnValue({
      user: ref(null),
      loading: ref(false),
      error: ref({ message: '用户不存在' })
    })
    
    const wrapper = mount(UserProfile, {
      props: { userId: 999 }
    })
    
    expect(wrapper.text()).toContain('错误: 用户不存在')
  })
})

Mock API 请求

我们再来看一个 Mock API 请求:

// composables/useApi.js
import { ref } from 'vue'

export function useApi() {
  const data = ref(null)
  const loading = ref(false)
  const error = ref(null)
  
  const fetchData = async (url) => {
    loading.value = true
    error.value = null
    
    try {
      const response = await fetch(url)
      data.value = await response.json()
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  }
  
  return { data, loading, error, fetchData }
}

其对应的测试:

// __tests__/useApi.spec.js
import { describe, it, expect, vi } from 'vitest'
import { useApi } from '../useApi'

describe('useApi', () => {
  it('请求成功', async () => {
    // Mock fetch
    const mockData = { id: 1, name: '测试' }
    global.fetch = vi.fn().mockResolvedValue({
      json: vi.fn().mockResolvedValue(mockData)
    })
    
    const { fetchData, data, loading, error } = useApi()
    
    // 初始状态
    expect(loading.value).toBe(false)
    expect(data.value).toBe(null)
    
    // 请求中
    const promise = fetchData('/api/test')
    expect(loading.value).toBe(true)
    
    // 等待请求完成
    await promise
    
    expect(loading.value).toBe(false)
    expect(data.value).toEqual(mockData)
    expect(error.value).toBe(null)
  })
  
  it('请求失败', async () => {
    global.fetch = vi.fn().mockRejectedValue(new Error('网络错误'))
    
    const { fetchData, data, loading, error } = useApi()
    
    await fetchData('/api/test')
    
    expect(loading.value).toBe(false)
    expect(data.value).toBe(null)
    expect(error.value).toBeInstanceOf(Error)
  })
})

测试最佳实践

测试行为,而非实现细节

不好的测试:关注内部实现细节

it('calls validateEmail function', () => {
  const validateSpy = vi.spyOn(LoginForm.methods, 'validateEmail')
  // ... 测试
  expect(validateSpy).toHaveBeenCalled()
})

好的测试:关注用户可见的行为

it('shows error message when email is invalid', async () => {
  const wrapper = mount(LoginForm)
  
  await wrapper.find('input[type="email"]').setValue('invalid-email')
  await wrapper.find('button[type="submit"]').trigger('click')
  
  expect(wrapper.text()).toContain('请输入有效的邮箱地址')
})

测试公共 API,而非私有状态

// composables/useCounter.ts
export function useCounter() {
  const count = ref(0)  // 内部状态
  const increment = () => count.value++
  
  // ✅ 测试公共 API
  return {
    count,      // 只读状态(通过 ref 暴露)
    increment   // 方法
  }
}

// ✅ 好的测试
it('increments count when increment is called', () => {
  const { count, increment } = useCounter()
  increment()
  expect(count.value).toBe(1)
})

测试边界情况和错误场景

it('handles empty list', () => {
  const wrapper = mount(ProductList, {
    props: { products: [] }
  })
  expect(wrapper.text()).toContain('暂无商品')
})

it('handles extremely long text', () => {
  const longText = 'a'.repeat(1000)
  const wrapper = mount(ProductCard, {
    props: { title: longText }
  })
  // 测试是否被截断或换行
})

it('handles API timeout', async () => {
  vi.mocked(fetch).mockImplementationOnce(
    () => new Promise(resolve => setTimeout(resolve, 10000))
  )
  
  const { fetchData, loading } = useApi()
  const promise = fetchData('/api/test')
  
  expect(loading.value).toBe(true)
  
  // 模拟超时
  await expect(promise).rejects.toThrow('Timeout')
})

测试描述要清晰

不好的测试描述

it('works correctly')
it('handles state')
it('test button')

好的测试描述

it('提交按钮在表单提交时禁用')
it('密码少于6位时显示错误')
it('登录成功后跳转到首页')

保持测试独立

describe('UserStore', () => {
  beforeEach(() => {
    // 每个测试前重置状态
    vi.clearAllMocks()
    localStorage.clear()
  })
  
  it('test 1', () => {})
  it('test 2', () => {})  // 不受 test 1 影响
})

测试覆盖率

配置测试覆盖率

// vite.config.js
export default {
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html', 'lcov'],
      include: ['src/**/*.{js,ts,vue}'],
      exclude: [
        'src/**/*.test.ts',
        'src/**/*.spec.ts',
        'src/main.ts',
        'src/router/**'
      ],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 75,
        statements: 80
      }
    }
  }
}

查看覆盖率

npm run test:coverage

# 输出:
File              | % Stmts | % Branch | % Funcs | % Lines
------------------|---------|----------|---------|--------
src/composables/  |   85.71 |    75.00 |   90.00 |   85.71
src/components/   |   72.50 |    66.67 |   80.00 |   72.50
src/utils/        |  100.00 |   100.00 |  100.00 |  100.00

常见问题与解决方案

问题一:组合式函数中的生命周期不执行

// ❌ 错误:直接调用
const result = useWindowWidth()

// ✅ 正确:使用 withSetup
const [result, app] = withSetup(() => useWindowWidth())

问题二:异步测试超时

// ❌ 错误:没有等待异步操作
it('fetches data', () => {
  const { fetchData } = useApi()
  fetchData('/api/test')
  expect(data.value).toBeDefined() // 可能还没返回
})

// ✅ 正确:等待异步操作
it('fetches data', async () => {
  const { fetchData, data } = useApi()
  await fetchData('/api/test')
  expect(data.value).toBeDefined()
})

问题三:测试 DOM 更新

// ❌ 错误:没有等待 DOM 更新
it('updates count', () => {
  wrapper.vm.count++
  expect(wrapper.find('.count').text()).toBe('1') // 可能失败
})

// ✅ 正确:使用 nextTick 或 async
it('updates count', async () => {
  wrapper.vm.count++
  await nextTick()
  expect(wrapper.find('.count').text()).toBe('1')
})

问题四:Mock 没有被正确应用

// ❌ 错误:import 在 mock 之前
import { useUser } from './useUser'
vi.mock('./useUser') // 太晚了,模块已经加载

// ✅ 正确:mock 在 import 之前
vi.mock('./useUser')
import { useUser } from './useUser'

测试的最佳实践

测试优先级

  1. 核心业务逻辑(组合式函数)→ 必须测试
  2. 关键用户路径(组件)→ 必须测试
  3. 错误边界 → 必须测试
  4. UI 细节 → 可选

检查清单

  • 每个组合式函数都有单元测试
  • 每个关键组件都有组件测试
  • 测试覆盖了成功和失败两种情况
  • 测试描述清晰,说明预期行为
  • Mock 了外部依赖
  • 测试可以独立运行
  • CI 中自动运行测试

结语

测试不是为了 100% 覆盖率,而是为了重构时的信心。 一个没有测试的项目,重构就是重写;一个有测试的项目,重构是优化。代码是写给人看的,测试是写给未来的自己看的!

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!