← 中文资源

简体中文版本

异步断言模式

异步断言弥合测试时间与 promise 时间:await 结果、带截止期限轮询条件、对已落定状态断言——否则套件报错错误或根本不报。

阅读英文原文

概念定义

测试体先于异步世界结束。没有同步,断言跑在半完成状态上、因与 bug 无关的原因失败——或在进程退出前什么都没抛而空洞地通过。

工作机制

直接 promise:return 或 await 让框架等待落定。最终条件:带截止期限轮询——早落定就快、永不落定也有界。虚拟时间:假定时器瞬间推进过重试与退避。失败分诊靠消息与设计区分“断言失败”与“同步超时”两种不同故障。

使用场景

一切断言“状态被回调、worker 或最终一致性改变”的测试。假定时器与实时轮询的混用要罕见而刻意——混合时间域滋生微妙的非确定性。

常见误区

返回 promise 只覆盖直接结果:间接收敛(消费者已处理、缓存已刷新)需要显式等待。到处 sleep 500ms 把套件时长钉在最坏情况、CI 争用下照样 flaky——应带期限轮询。超时不等于功能坏了,只代表窗口内未落定,原因可能在测试环境或断言本身;空洞测试(异步函数解析却什么都没断言)报绿是最危险的形态;未处理的拒绝会逃出测试边界打崩无关用例。

代码示例

export interface EventuallyOptions {
  timeoutMs?: number;
  intervalMs?: number;
  signal?: AbortSignal;
}

export async function eventually<T>(
  predicate: () => T | Promise<T>,
  options: EventuallyOptions = {},
): Promise<T> {
  const { timeoutMs = 2000, intervalMs = 50, signal } = options;
  if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
    throw new RangeError("timeoutMs must be a non-negative finite number");
  }
  if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
    throw new RangeError("intervalMs must be a positive finite number");
  }
  const deadline = Date.now() + timeoutMs;
  let lastError: unknown;
  while (true) {
    if (signal?.aborted) {
      const aborted = new Error("eventually aborted");
      aborted.name = "AbortError";
      throw aborted;
    }
    try {
      const value = await predicate();
      if (value) return value;
    } catch (error) {
      lastError = error;
    }
    if (Date.now() >= deadline) {
      const message = "eventually timed out after " + timeoutMs + "ms";
      throw lastError ? new Error(message, { cause: lastError }) : new Error(message);
    }
    const wait = Math.min(intervalMs, Math.max(0, deadline - Date.now()));
    await new Promise<void>((resolve) => setTimeout(resolve, wait));
  }
}
export function mapPool(items, mapper, concurrency = 4) {
  if (!Array.isArray(items)) throw new TypeError("items must be an array");
  if (typeof mapper !== "function") throw new TypeError("mapper must be a function");
  if (!Number.isInteger(concurrency) || concurrency < 1) {
    throw new RangeError("concurrency must be a positive integer");
  }

  const results = new Array(items.length);
  let nextIndex = 0;

  async function worker() {
    while (nextIndex < items.length) {
      const index = nextIndex;
      nextIndex += 1;
      results[index] = await mapper(items[index], index);
    }
  }

  const workerCount = Math.min(concurrency, items.length);
  const workers = Array.from({ length: workerCount }, () => worker());
  return Promise.all(workers).then(() => results);
}