概念定义
测试体先于异步世界结束。没有同步,断言跑在半完成状态上、因与 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);
}