08|组合式函数的可测试性
第四章概览 / 08。目标:按依赖层级选择最小测试宿主,并让时间、网络与浏览器对象可替换。
不是所有 composable 都要挂组件
| composable 使用了什么 | 最小测试宿主 | 要验证什么 |
|---|---|---|
只有 ref / computed 与普通函数 | 直接调用 | 输入输出、每次调用隔离、错误合同 |
watch / onScopeDispose,不需要组件实例 | effectScope() | effect 更新与 scope 停止后的清理 |
onMounted、inject、模板 ref | 测试组件 + Vue Test Utils | 挂载归属、DOM、provide/inject、卸载 |
onServerPrefetch 或请求级 provider | createSSRApp + renderToString | 请求隔离、服务端数据与 HTML |
测试宿主越大,建立和排错成本越高。先把无 Vue 实例依赖的计算直接测;只有钩子合同需要组件时才挂载组件。
最小可运行例子:不用 DOM 验证 scope 清理
Node内置assert用于检查预期值:assert.equal(a,b)不相等会抛错;assert.throws(fn,RangeError)要求函数确实抛指定类型,错误实现会让进程失败。无需先安装测试运行器。
下面两个 .mjs 文件只依赖 Vue 与 Node 内置断言,可用 node ticker.test.mjs 运行。
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 }
}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 和随机数。把它们集中到默认依赖:
interface Runtime {
now(): number
fetch(input: RequestInfo, init?: RequestInit): Promise<Response>
target?: Window
}生产调用使用浏览器实现;测试传 fake;SSR 传服务端 fetch 且不提供 Window。依赖对象应按调用或请求创建,不把带用户状态的 fake / client 放成模块单例。
受控 Promise 才能证明竞态
“连续点了几次都没出错”不能证明旧请求不会覆盖新请求。测试应自己决定完成顺序:
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() 后断言清理。
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()幂等;每次 faketick()只增长一次。- 两次 composable 调用的 ticks 独立,即使共用同一个 fake clock,也要用不同句柄区分。
scope.stop()必须触发一次清理,之后 tick 不再更新。- 故意删除
onScopeDispose(stop),确认测试会失败;再恢复实现。
参考答案:查看第 08 题。