组合式函数(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 的组合函数
有些组合函数内部使用了 onMounted、watchEffect 等,它们需要在一个 Vue 组件的生存环境(setup 作用域)中才能触发。这时可以借助 @vue/test-utils 的 mount 创建一个极简组件来运行该函数。
待测试代码 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/await 或 watchEffect,可以利用 Vitest 的 nextTick 或 waitFor 等待更新。
待测试代码 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.waitFor 或 flushPromises,实际项目中建议结合 @vue/test-utils 的 flushPromises 工具。
测试注意事项
- 隔离副作用:每个测试用例最好使用
beforeEach重置共享的状态,避免用例间相互污染。 - 不测试 Vue 内部实现:组合函数的测试应该关注外部行为和返回值,而不是去验证内部的响应式依赖是如何建立的。只要最终状态正确即可。
- 使用 Mock 代替真实服务:测试数据请求时务必模拟
fetch或 axios,保证测试快速、稳定。 - 覆盖核心路径与边界情况:初始值、空值、错误处理、竞态条件等,都是组合函数容易出问题的地方。
- 与组件测试的边界:如果一个组合函数大量依赖于 DOM 元素或模板交互,那么更适合通过组件测试来验证其整体表现,而不是硬去测试组合函数本身。
组合式函数的单元测试是 Vue 项目中最容易建立的一层防护网。它编写快速、执行即时,能在重构逻辑时给你充分的信心。遵循“测试行为而非实现细节”的原则,你的测试用例就会稳定而有效。