02|组合式函数测试
第九章 / 02。目标:让时间、请求、宿主与清理都成为可控制输入。
先判断是否需要组件宿主
只由 ref/computed/watch 组成的 composable 可直接调用;使用 onMounted/onUnmounted/provide/inject 的 composable 必须在真实组件 setup 中运行,否则生命周期不会发生。
import { createApp, type App } from 'vue'
export function withSetup<T>(useComposable: () => T) {
let result!: T
const host = document.createElement('div')
const app: App = createApp({
setup() {
result = useComposable()
return () => null
}
})
app.mount(host)
return { result, unmount: () => app.unmount() }
}这个 helper 提供 lifecycle,不会自动安装 router/Pinia;需要的插件应显式注入,防止测试靠全局残留偶然通过。
把 helper 保存为 src/test/withSetup.ts。纯响应式 composable 若创建 watch,也应包进 effectScope 并在测试后 stop,不能因不挂载组件就省略资源清理。
时间与清理实验
先用一个只负责启动和清理计时器的最小对象建立因果。保存为 src/useHeartbeat.ts:
import { onMounted, onUnmounted } from 'vue'
export function useHeartbeat(tick: () => void, intervalMs: number) {
if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
throw new RangeError('intervalMs must be positive')
}
let timer: ReturnType<typeof setInterval> | undefined
onMounted(() => { timer = setInterval(tick, intervalMs) })
onUnmounted(() => {
if (timer) clearInterval(timer)
timer = undefined
})
}再保存 src/useHeartbeat.spec.ts:
import { afterEach, expect, test, vi } from 'vitest'
import { useHeartbeat } from './useHeartbeat'
import { withSetup } from './test/withSetup'
afterEach(() => vi.useRealTimers())
test('挂载后启动,卸载后停止', async () => {
vi.useFakeTimers()
const tick = vi.fn()
const { unmount } = withSetup(() => useHeartbeat(tick, 1000))
expect(tick).toHaveBeenCalledTimes(0)
await vi.advanceTimersByTimeAsync(3000)
expect(tick).toHaveBeenCalledTimes(3)
unmount()
await vi.advanceTimersByTimeAsync(2000)
expect(tick).toHaveBeenCalledTimes(3)
})这里有三段独立证据。挂载完成但尚未推进时间时计数为0,说明 interval 不会同步调用;推进3000毫秒后计数恰为3;卸载后再推进时间不增长,说明停止来自卸载钩子,而不是计时器碰巧没到期。若删除 clearInterval,最后一个断言会得到5,测试能排除漏清理实现。
异步轮询还多出 Promise 失败和并发重叠。先建立 Promise 的拒绝断言,再推进会触发拒绝的假计时器,避免 unhandled rejection 抢先发生。每个测试恢复 timer、卸载 app、清理事件监听器和 mock。正文先用同步 heartbeat 教会宿主与清理;文末练习再把它迁移为可暂停、不可重叠的异步协议。
依赖比 mock 全局更清楚
export function useClock(now = () => Date.now()) {
const startedAt = now()
return { elapsed: () => now() - startedAt }
}把 now/fetch/storage 作为参数可让输入和失败可控,也减少全局 mock 互相污染。浏览器 API 的兼容性仍需浏览器/E2E 测试,单元替身只能证明组合式函数按合同调用它。
练习:可暂停轮询
实现并测试 usePolling(load, intervalMs),返回 { active, pause, resume, lastError }。load 无参数并返回 Promise<void>;interval 必须为有限正数;挂载后每周期调用一次,不允许并发重叠;pause 与卸载都清理 timer;load reject 时保存原始 error 并继续允许 resume。分别观察调用次数、active、原始错误身份和卸载后的计数,用 fake timers 与可控 Promise 证明每一条。当前合同只阻止新调用,不要求取消已经开始的 Promise。
参考答案:第 9 章第 02 题。