前端怎样测试定时器、重试和超时,避免 Fake Timers 造成假通过?
解释宏任务、微任务和真实时钟的关系,设计可控的定时器测试,覆盖轮询、指数退避、AbortSignal 和测试环境清理。
简答
- 定时器回调和 Promise 微任务不在同一队列。
- 推进 Fake Timers 不一定执行定时器回调里产生的微任务。
- 优先使用测试框架提供的异步推进方法,不要手动猜 await 次数。
- 可以向被测代码注入计时器,让时间可控。
搜索框请求失败后每秒重试,接口连续失败三次后显示错误;测试看起来只用了几毫秒,却声称已经验证了四秒的退避过程。另一个测试开启 Fake Timers 后,Promise 回调没有执行,开发者只能不断增加 await。问题通常不是断言写错,而是把定时器、微任务和真实时间当成了同一种队列。
定时器和 Promise 不在同一队列
setTimeout、setInterval 等回调由计时器任务调度;Promise continuation 和 queueMicrotask 属于微任务。推进 Fake Timers 的时间通常只会让到期的计时器进入待执行状态,不一定自动执行由计时器回调创建的 Promise 微任务。测试框架提供的异步推进方法应优先使用,而不是手动猜测需要多少个 await。
export async function retryAfter(task, delays, setTimer = setTimeout) {
if (!Array.isArray(delays) || !Array.from(delays).every((ms) =>
Number.isInteger(ms) && ms >= 0 && ms <= 2147483647)) {
throw new RangeError('退避值必须是有效的非负整数毫秒数');
}
const schedule = [...delays];
for (let attempt = 0; ; attempt += 1) {
try {
return await task();
} catch (error) {
if (attempt === schedule.length) throw error;
}
// 等待失败也会拒绝外层 Promise,不留下悬挂任务。
await new Promise((resolve) => setTimer(resolve, schedule[attempt]));
}
}
在测试中可以注入计时器,或使用 Vitest/Jest 的 fake timers。依赖注入让纯业务逻辑不必知道测试框架,但仍要验证真实浏览器中的 AbortController、网络失败和页面卸载行为;Fake Timers 只能控制 JavaScript 时间,不会模拟真实网络和渲染调度。
测试重试时要推进“任务完成后的时间”
如果第一次任务同步失败,计时器可能立刻被安排;如果任务返回 Promise,测试需要先让 Promise 微任务完成,再推进下一次计时器。一个稳妥的流程是:启动请求,等待初始微任务,推进到下一次退避时间,等待异步回调,再断言下一次调用。
import { afterEach, expect, it, vi } from 'vitest';
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
vi.restoreAllMocks();
});
it('按退避时间重试,并在成功后停止', async () => {
vi.useFakeTimers();
const task = vi
.fn()
.mockRejectedValueOnce(new Error('暂时失败'))
.mockResolvedValueOnce('ok');
const promise = retryAfter(task, [1000]);
await vi.advanceTimersByTimeAsync(0);
expect(task).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(999);
expect(task).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
await expect(promise).resolves.toBe('ok');
expect(task).toHaveBeenCalledTimes(2);
expect(vi.getTimerCount()).toBe(0);
});
it('失败耗尽后停止,不留下下一次重试', async () => {
vi.useFakeTimers();
const error = new Error('持续失败');
const task = vi.fn().mockRejectedValue(error);
const promise = retryAfter(task, [100, 200]);
// 先安装拒绝断言,避免推进时先触发未处理拒绝。
const assertion = expect(promise).rejects.toBe(error);
await vi.advanceTimersByTimeAsync(300);
await assertion;
expect(task).toHaveBeenCalledTimes(3);
expect(vi.getTimerCount()).toBe(0);
});
it('拒绝稀疏或非法退避值,并且不执行任务', async () => {
const task = vi.fn();
await expect(retryAfter(task, new Array(2))).rejects.toThrow(RangeError);
await expect(retryAfter(task, [-1])).rejects.toThrow(RangeError);
await expect(retryAfter(task, [NaN])).rejects.toThrow(RangeError);
expect(task).not.toHaveBeenCalled();
});
advanceTimersByTimeAsync 是 Vitest 当前提供的异步推进方式之一;不同测试框架名称和微任务处理细节可能不同,应以锁定版本的文档为准。不要把 runAllTimers() 当成所有异步测试的默认方案:无限轮询或递归定时器会让测试永不结束,应该设置最大尝试次数并断言停止条件。
轮询必须有结束条件和可取消路径
轮询代码如果只依赖 setInterval,可能在上一次请求尚未结束时发起下一次请求,造成并发重叠。测试应验证请求失败、成功、超时、取消和组件卸载:
- 成功后不再安排下一次计时器;
- 达到最大次数后拒绝或进入错误状态;
AbortSignal取消正在等待的请求;- 卸载后没有遗留定时器和状态更新;
- 重试间隔不会因错误路径被重复叠加。
更容易测试的实现通常是“请求完成后再安排下一次 setTimeout”,而不是固定间隔 setInterval。定时器测试还要检查参数,避免只断言最终文本而漏掉实际等待时间错误。
Fake Timers 的边界
Fake Timers 通常会替换 Date、setTimeout、setInterval、requestAnimationFrame 等部分时间 API,但具体覆盖范围取决于框架和配置。performance.now()、queueMicrotask、原生网络、Worker 和浏览器渲染未必被同样控制。代码如果混用多个时钟源,测试中的“经过了 5 秒”可能与业务判断不一致。
不要在同一个测试文件里把 Fake Timers 留到下一个测试。每个测试结束后恢复真实时钟、清除 mock 和未完成的请求;否则后续测试可能在假的系统时间下运行,出现顺序相关的随机失败。
什么时候应使用真实时间
Fake Timers 适合验证退避公式、最大次数和状态转换,能让测试快速、稳定。真实时间测试适合验证浏览器集成、实际网络超时、页面可见性、后台限频和动画呈现,但应缩小范围并设置合理上限。端到端测试不应等待很长的真实退避,可以在测试环境注入更短的策略或使用可控的服务端时钟。
常见误区
- 推进计时器后马上断言 Promise 结果:微任务可能还没执行。
- 只测试成功路径:失败、取消和卸载最容易留下泄漏。
- 用
runAllTimers测无限轮询:测试会死循环或掩盖缺少停止条件。 - 不恢复真实时钟:后续测试会受到隐藏状态影响。
- 把 Fake Timers 通过当作真实浏览器定时精度保证:后台标签页和系统调度仍可能改变实际行为。
测试目标应是验证业务状态和停止条件,而不是验证测试框架的时钟实现。把重试策略、计时器和网络任务拆成可注入的边界,再用少量真实浏览器测试补足调度、可见性和取消行为,通常比单纯增加等待时间更可靠。