Skip to content

08|组合式函数的可测试性 ​

第四章概览 / 08。目标:按依赖层级选择最小测试宿主,并让时间、网络与浏览器对象可替换。

不是所有 composable 都要挂组件 ​

composable 使用了什么最小测试宿主要验证什么
只有 ref / computed 与普通函数直接调用输入输出、每次调用隔离、错误合同
watch / onScopeDispose,不需要组件实例effectScope()effect 更新与 scope 停止后的清理
onMounted、inject、模板 ref测试组件 + Vue Test Utils挂载归属、DOM、provide/inject、卸载
onServerPrefetch 或请求级 providercreateSSRApp + renderToString请求隔离、服务端数据与 HTML

测试宿主越大,建立和排错成本越高。先把无 Vue 实例依赖的计算直接测;只有钩子合同需要组件时才挂载组件。

最小可运行例子:不用 DOM 验证 scope 清理 ​

Node内置assert用于检查预期值:assert.equal(a,b)不相等会抛错;assert.throws(fn,RangeError)要求函数确实抛指定类型,错误实现会让进程失败。无需先安装测试运行器。

下面两个 .mjs 文件只依赖 Vue 与 Node 内置断言,可用 node ticker.test.mjs 运行。

js
import { onScopeDispose, readonly, ref } from 'vue'

export function useTicker(clock, intervalMs = 1000) {
  if (intervalMs <= 0) throw new RangeError('intervalMs 必须大于 0')

  const ticks = ref(0)
  let handle

  function start() {
    if (handle !== undefined) return
    handle = clock.setInterval(() => { ticks.value += 1 }, intervalMs)
  }

  function stop() {
    if (handle === undefined) return
    clock.clearInterval(handle)
    handle = undefined
  }

  onScopeDispose(stop)
  return { ticks: readonly(ticks), start, stop }
}
js
import assert from 'node:assert/strict'
import { effectScope } from 'vue'
import { useTicker } from './useTicker.mjs'

function createClock() {
  let callback
  let clears = 0
  return {
    setInterval(next) { callback = next; return 1 },
    clearInterval() { callback = undefined; clears += 1 },
    tick() { callback?.() },
    get clears() { return clears }
  }
}

const clock = createClock()
const scope = effectScope()
const ticker = scope.run(() => useTicker(clock, 20))

assert.ok(ticker)
ticker.start()
ticker.start() // 幂等:仍只有一个回调
clock.tick()
assert.equal(ticker.ticks.value, 1)

scope.stop()
clock.tick()
assert.equal(ticker.ticks.value, 1)
assert.equal(clock.clears, 1)
assert.throws(() => useTicker(clock, 0), RangeError)

console.log('ticker contract passed')

effectScope() 提供了 onScopeDispose 所需的活动 scope,并让测试主动触发清理。它不伪装组件挂载:若实现改用 onMounted,这个测试宿主就不够。

依赖注入不是“为了 mock” ​

主例接收 clock,资源页的 useAutoRefresh 接收 fetcher 与可选 target。这首先是环境合同:代码明确说自己需要时间源、请求能力和事件目标;测试只是因此能给出受控实现。

不要直接在 composable 内到处读取 Date.now()、window、真实 fetch 和随机数。把它们集中到默认依赖:

ts
interface Runtime {
  now(): number
  fetch(input: RequestInfo, init?: RequestInit): Promise<Response>
  target?: Window
}

生产调用使用浏览器实现;测试传 fake;SSR 传服务端 fetch 且不提供 Window。依赖对象应按调用或请求创建,不把带用户状态的 fake / client 放成模块单例。

受控 Promise 才能证明竞态 ​

“连续点了几次都没出错”不能证明旧请求不会覆盖新请求。测试应自己决定完成顺序:

ts
function deferred<T>() {
  let resolve!: (value: T) => void
  let reject!: (reason?: unknown) => void
  const promise = new Promise<T>((ok, fail) => {
    resolve = ok
    reject = fail
  })
  return { promise, resolve, reject }
}

让第一次请求持有 first.promise,第二次持有 second.promise;先完成 second,再完成或拒绝 first。断言最终 data 仍是 second,旧请求 signal 已 aborted,且旧 finally 不会提前关闭当前 loading。

什么时候挂测试组件 ​

若 composable 使用 onMounted、inject 或 useTemplateRef,测试必须提供真实组件上下文。最小做法是定义一个宿主组件,在其 setup() 同步调用 composable,挂载后读公开返回,并在 wrapper.unmount() 后断言清理。

ts
const Host = defineComponent({
  setup() {
    return useNetworkStatus(20)
  },
  template: '<p>{{ label }}</p>'
})

const wrapper = mount(Host)
// 断言挂载后的行为
wrapper.unmount()
// 断言 listener / timer 已释放

这段需要测试运行器、DOM 环境与 @vue/test-utils;它不是当前两个 Node 文件的一部分。对于 provide/inject,测试整个最小父子树通常比 mock inject() 更接近真实归属。

应断言公开行为与失败路径 ​

  • 直接调用两次 composable,证明 refs 与错误状态隔离。
  • 非法输入应抛错或返回明确失败,测试不能只看成功值。
  • fake timer 要断言注册数量、清理数量和清理后不再触发。
  • Abort 测试区分主动取消与普通网络错误。
  • 组件测试在 unmount 后继续触发 fake event,确认没有回调。
  • SSR 测试并行渲染两个请求,检查 HTML 与 store 不串数据。

避免只断言“某函数被调用”。若错误实现调用了两次、传错 signal 或清理后仍调用,这种断言仍可能通过。

练习:测试 useTicker ​

按主例重写实现和测试,合同如下:

  • intervalMs <= 0 抛 RangeError;正常构造不自动启动。
  • start() 幂等,stop() 幂等;每次 fake tick() 只增长一次。
  • 两次 composable 调用的 ticks 独立,即使共用同一个 fake clock,也要用不同句柄区分。
  • scope.stop() 必须触发一次清理,之后 tick 不再更新。
  • 故意删除 onScopeDispose(stop),确认测试会失败;再恢复实现。

参考答案:查看第 08 题。

用心记录每一步成长