异步、定时器与竞态
异步测试最危险的假绿是:测试函数已经返回,但被测 Promise 还没有结算。第二种假绿是:本次响应碰巧先回来,掩盖了下一次输入覆盖旧结果的竞态。解决办法不是加更长的 sleep,而是把完成信号、请求顺序和取消动作都变成显式输入。
从可控 Promise 开始
先实现一个只接受最新结果的加载器。version 是提交前的逻辑时钟;AbortController 负责告诉底层请求释放资源,二者职责不同:即使底层忽略 abort,版本检查仍保护状态。
// latest-only.ts
export function latestOnly<T>(
fetcher: (key: string, signal: AbortSignal) => Promise<T>,
) {
let version = 0
let controller: AbortController | undefined
return async (key: string) => {
controller?.abort()
controller = new AbortController()
const current = ++version
try {
const value = await fetcher(key, controller.signal)
return current === version ? { kind: 'success' as const, value } : { kind: 'stale' as const }
} catch (error) {
if (current !== version || controller?.signal.aborted) return { kind: 'stale' as const }
return { kind: 'error' as const, error }
}
}
}测试不用网络,自己保留每个 key 的 resolver:
import { expect, it } from 'vitest'
import { latestOnly } from './latest-only'
it('旧响应回来也不能覆盖新结果', async () => {
const pending = new Map<string, (value: string) => void>()
const loader = latestOnly((key) => new Promise<string>(resolve => pending.set(key, resolve)))
const oldResult = loader('old')
const newResult = loader('new')
pending.get('new')!('NEW')
await expect(newResult).resolves.toEqual({ kind: 'success', value: 'NEW' })
pending.get('old')!('OLD')
await expect(oldResult).resolves.toEqual({ kind: 'stale' })
})如果把 current === version 删除,最后一个断言会收到 OLD,测试才真正区分正确和错误实现。
debounce 与 fake timer
防抖把“最后一次输入”与“延迟提交”分开:
export function debounce<T extends (...args: any[]) => void>(fn: T, delay: number) {
let timer: ReturnType<typeof setTimeout> | undefined
return (...args: Parameters<T>) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => fn(...args), delay)
}
}测试步骤必须先注册 fake timer,再输入三次,推进时间,最后等待任何异步回调:
vi.useFakeTimers()
const search = vi.fn()
const run = debounce(search, 300)
run('v'); run('vu'); run('vue')
vi.advanceTimersByTime(299)
expect(search).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(search).toHaveBeenCalledOnce()
expect(search).toHaveBeenCalledWith('vue')
vi.useRealTimers()若回调里有 Promise,advanceTimersByTimeAsync 或显式 await 才能让微任务队列完成;fake timer 不等于“所有异步都完成”。测试钩子里恢复真实 timer,避免影响同文件后续用例。
错误、取消和恢复
错误测试应等待业务 Promise:await expect(load()).rejects.toThrow(...)。不要只断言函数被调用,因为那无法证明失败状态已写入。取消是正常控制流时,业务层应将其映射为 stale/cancelled,不要把用户主动输入变化显示成红色错误;真正网络失败则应产生 error,并允许下一次输入创建新的 controller 与 Promise。
练习:三条互相区分的断言
为带 debounce 的搜索补三条测试:
- 250ms 内输入三次,只调用一次 fetcher,并使用最后一个 key。
- 第一请求尚未结算时开始第二次请求,先 resolve 第一请求,状态不能变成旧结果;再 resolve 第二请求,状态才成功。
- 当前请求以网络错误结束后,状态为 error;下一次输入成功,状态回到 success,不能复用 rejected Promise。
要求保留 pending map,禁止 setTimeout 等待真实时间。答案页会展示如何分别控制宏任务、微任务和 Promise resolver。
下一步阅读组件行为、浏览器模式与 CI。