人人都会AI编程

组合式函数单元测试

更新时间:2026-07-11

组合式函数(Composables)是 Vue 3 中复用逻辑的核心方式。因为它们本质上是普通的 JavaScript 函数,不依赖于组件模板,所以单元测试写起来比组件测试更轻量、更纯粹。你不需要模拟 DOM 结构,只需要验证“给定输入,能否得到正确的输出或副作用”。

测试环境搭建

推荐使用 Vitest(Vite 原生测试框架,与 Vue 项目无缝集成)配合 @vue/test-utils 提供的工具函数。后者里的 mount 方法虽然主要用于组件,但在测试需要组件上下文(如生命周期钩子)的组合函数时,可以通过挂载一个最小化的组件来触发。大多数情况下,直接执行组合函数并断言其返回值就足够了。

安装依赖:

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

vite.config.js 中配置测试环境:

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

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'jsdom', // 如果涉及 DOM 相关测试
  }
})

测试一个纯逻辑的组合函数

最常见的情况是:组合函数接收参数,返回响应式数据或方法,不涉及生命周期。

待测试代码 useCounter.js

import { ref, computed } from 'vue'

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

  function increment() {
    count.value++
  }
  function decrement() {
    count.value--
  }

  return { count, doubled, increment, decrement }
}

测试用例 useCounter.test.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('increment 应将 count 加 1', () => {
    const { count, increment } = useCounter(5)
    increment()
    expect(count.value).toBe(6)
  })

  it('decrement 应将 count 减 1', () => {
    const { count, decrement } = useCounter(5)
    decrement()
    expect(count.value).toBe(4)
  })

  it('doubled 应该是 count 的两倍', () => {
    const { count, doubled, increment } = useCounter(3)
    expect(doubled.value).toBe(6)
    count.value = 10
    expect(doubled.value).toBe(20)
  })
})

直接调用函数,操作返回值并断言 .value 即可,与测试普通工具函数一样简单。

测试包含生命周期或 watch 的组合函数

有些组合函数内部使用了 onMountedwatchEffect 等,它们需要在一个 Vue 组件的生存环境(setup 作用域)中才能触发。这时可以借助 @vue/test-utilsmount 创建一个极简组件来运行该函数。

待测试代码 useEventListener.js

import { onMounted, onUnmounted } from 'vue'

export function useEventListener(target, event, callback) {
  onMounted(() => target.addEventListener(event, callback))
  onUnmounted(() => target.removeEventListener(event, callback))
}

测试用例(使用 mount 提供组件上下文):

import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { useEventListener } from './useEventListener'

describe('useEventListener', () => {
  it('应在挂载时添加事件监听,卸载时移除', () => {
    const target = document.createElement('div')
    const handler = vi.fn()

    const wrapper = mount({
      setup() {
        useEventListener(target, 'click', handler)
        return {}
      },
      template: '<div />'
    })

    // 模拟点击
    target.dispatchEvent(new Event('click'))
    expect(handler).toHaveBeenCalledTimes(1)

    // 卸载组件
    wrapper.unmount()
    target.dispatchEvent(new Event('click'))
    expect(handler).toHaveBeenCalledTimes(1) // 不应也再增加
  })
})

关键点:通过 mount 包裹一个空模板,该组件的 setup 内执行组合函数,生命周期便会正常触发。

测试异步组合函数

组合函数中如果有 async/awaitwatchEffect,可以利用 Vitest 的 nextTickwaitFor 等待更新。

待测试代码 useFetch.js

import { ref } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const loading = ref(false)

  async function fetchData() {
    loading.value = true
    const res = await fetch(url)
    data.value = await res.json()
    loading.value = false
  }

  return { data, loading, fetchData }
}

测试用例

import { describe, it, expect, vi } from 'vitest'
import { nextTick } from 'vue'
import { useFetch } from './useFetch'

describe('useFetch', () => {
  it('应在 fetchData 后更新 data', async () => {
    // 模拟 fetch
    global.fetch = vi.fn().mockResolvedValue({
      json: () => Promise.resolve({ name: 'Vue' })
    })

    const { data, loading, fetchData } = useFetch('/api/user')
    expect(loading.value).toBe(false)

    fetchData()
    // 注意:fetchData 是异步的,需要等待微任务完成
    await nextTick() // 或 await new Promise(r => setTimeout(r, 0))
    expect(loading.value).toBe(true) // 请求发出后 loading 为真
    // 但 fetch 本身也是异步的,需要等待整个 fetch 完成
    await new Promise(resolve => setTimeout(resolve, 10))
    expect(data.value).toEqual({ name: 'Vue' })
    expect(loading.value).toBe(false)
  })
})

更优雅的方式是使用 vi.waitForflushPromises,实际项目中建议结合 @vue/test-utilsflushPromises 工具。

测试注意事项

  1. 隔离副作用:每个测试用例最好使用 beforeEach 重置共享的状态,避免用例间相互污染。
  2. 不测试 Vue 内部实现:组合函数的测试应该关注外部行为和返回值,而不是去验证内部的响应式依赖是如何建立的。只要最终状态正确即可。
  3. 使用 Mock 代替真实服务:测试数据请求时务必模拟 fetch 或 axios,保证测试快速、稳定。
  4. 覆盖核心路径与边界情况:初始值、空值、错误处理、竞态条件等,都是组合函数容易出问题的地方。
  5. 与组件测试的边界:如果一个组合函数大量依赖于 DOM 元素或模板交互,那么更适合通过组件测试来验证其整体表现,而不是硬去测试组合函数本身。

组合式函数的单元测试是 Vue 项目中最容易建立的一层防护网。它编写快速、执行即时,能在重构逻辑时给你充分的信心。遵循“测试行为而非实现细节”的原则,你的测试用例就会稳定而有效。