Skip to content

异步、定时器与竞态 ​

异步测试最危险的假绿是:测试函数已经返回,但被测 Promise 还没有结算。第二种假绿是:本次响应碰巧先回来,掩盖了下一次输入覆盖旧结果的竞态。解决办法不是加更长的 sleep,而是把完成信号、请求顺序和取消动作都变成显式输入。

从可控 Promise 开始 ​

先实现一个只接受最新结果的加载器。version 是提交前的逻辑时钟;AbortController 负责告诉底层请求释放资源,二者职责不同:即使底层忽略 abort,版本检查仍保护状态。

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

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

防抖把“最后一次输入”与“延迟提交”分开:

ts
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,再输入三次,推进时间,最后等待任何异步回调:

ts
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 的搜索补三条测试:

  1. 250ms 内输入三次,只调用一次 fetcher,并使用最后一个 key。
  2. 第一请求尚未结算时开始第二次请求,先 resolve 第一请求,状态不能变成旧结果;再 resolve 第二请求,状态才成功。
  3. 当前请求以网络错误结束后,状态为 error;下一次输入成功,状态回到 success,不能复用 rejected Promise。

要求保留 pending map,禁止 setTimeout 等待真实时间。答案页会展示如何分别控制宏任务、微任务和 Promise resolver。

下一步阅读组件行为、浏览器模式与 CI。

用心记录每一步成长