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

前言

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

为什么需要测试?

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

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

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

场景2:新人接手代码

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

场景3:上线前的焦虑

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

测试的投资回报率

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

测试策略金字塔

text 复制代码
        /\
       /  \      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

bash 复制代码
# 安装 Vitest
npm install --save-dev vitest

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

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

Vite 配置集成

typescript 复制代码
// 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 文件
  }
})

添加测试脚本

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

第一个测试

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

javascript 复制代码
// src/utils/math.js
export function add(a, b) {
  return a + b
}

其对应的测试:

javascript 复制代码
// 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)
  })
})

测试组合式函数

最简单的组合式函数

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

javascript 复制代码
// 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 }
}

其对应的测试:

typescript 复制代码
// 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 的组合式函数:

javascript 复制代码
// 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 }
}

其对应的测试:

javascript 复制代码
// 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)
  })
})

带生命周期的组合式函数

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

javascript 复制代码
// 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 }
}

其对应的测试:

javascript 复制代码
// 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

bash 复制代码
npm install --save-dev @vue/test-utils

最简单的组件

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

html 复制代码
<!-- 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>

其对应的测试:

javascript 复制代码
// __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 的组件:

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

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

其对应的测试:

javascript 复制代码
// __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('欢迎使用我们的应用')
  })
})

带事件的组件

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

html 复制代码
<!-- 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>

其对应的测试:

javascript 复制代码
// __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 组合式函数:

html 复制代码
<!-- 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>

其对应的测试:

javascript 复制代码
// __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 请求:

javascript 复制代码
// 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 }
}

其对应的测试:

javascript 复制代码
// __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)
  })
})

测试最佳实践

测试行为,而非实现细节

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

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

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

javascript 复制代码
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,而非私有状态

typescript 复制代码
// 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)
})

测试边界情况和错误场景

typescript 复制代码
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')
})

测试描述要清晰

不好的测试描述

javascript 复制代码
it('works correctly')
it('handles state')
it('test button')

好的测试描述

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

保持测试独立

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

测试覆盖率

配置测试覆盖率

javascript 复制代码
// 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
      }
    }
  }
}

查看覆盖率

bash 复制代码
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

常见问题与解决方案

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

typescript 复制代码
// ❌ 错误:直接调用
const result = useWindowWidth()

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

问题二:异步测试超时

typescript 复制代码
// ❌ 错误:没有等待异步操作
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 更新

typescript 复制代码
// ❌ 错误:没有等待 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 没有被正确应用

typescript 复制代码
// ❌ 错误: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% 覆盖率,而是为了重构时的信心。 一个没有测试的项目,重构就是重写;一个有测试的项目,重构是优化。代码是写给人看的,测试是写给未来的自己看的!

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

相关推荐
橙子家27 分钟前
浏览器缓存之【结构化数据库与缓存】: IndexedDB、Cache storage 和 Storage buckets
前端
user205855615181333 分钟前
X6 中边悬浮置顶,规避 `mouseleave` 事件丢失问题
前端
李明卫杭州34 分钟前
CSS aspect-ratio 属性完全指南
前端
Pedantic2 小时前
SwiftUI 手势层级(Gesture Hierarchy)详解
前端
飘尘3 小时前
前端转型全栈(Java后端)的快速上手指引
前端·后端·全栈
一颗烂土豆3 小时前
Meshopt 压缩深度解析,为什么它比 Draco 更快
前端·javascript·webgl
浏览器工程师4 小时前
AI Agent 接浏览器任务,先别让它一路点到底
前端·后端
雨季mo浅忆4 小时前
VSCode自动格式化三要素
前端
爱勇宝5 小时前
深扒 Anthropic 1680 位工程师简历:应届生几乎没机会,AI 公司最缺的不是博士
前端·后端·程序员