Skip to content

02|组合式函数测试 ​

第九章 / 02。目标:让时间、请求、宿主与清理都成为可控制输入。

先判断是否需要组件宿主 ​

只由 ref/computed/watch 组成的 composable 可直接调用;使用 onMounted/onUnmounted/provide/inject 的 composable 必须在真实组件 setup 中运行,否则生命周期不会发生。

ts
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:

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:

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 全局更清楚 ​

ts
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 题。

用心记录每一步成长