前端知库

前端怎样测试定时器、重试和超时,避免 Fake Timers 造成假通过?

解释宏任务、微任务和真实时钟的关系,设计可控的定时器测试,覆盖轮询、指数退避、AbortSignal 和测试环境清理。

参考答案

简答

  • 定时器回调和 Promise 微任务不在同一队列。
  • 推进 Fake Timers 不一定执行定时器回调里产生的微任务。
  • 优先使用测试框架提供的异步推进方法,不要手动猜 await 次数。
  • 可以向被测代码注入计时器,让时间可控。

搜索框请求失败后每秒重试,接口连续失败三次后显示错误;测试看起来只用了几毫秒,却声称已经验证了四秒的退避过程。另一个测试开启 Fake Timers 后,Promise 回调没有执行,开发者只能不断增加 await。问题通常不是断言写错,而是把定时器、微任务和真实时间当成了同一种队列。

定时器和 Promise 不在同一队列

setTimeout、setInterval 等回调由计时器任务调度;Promise continuation 和 queueMicrotask 属于微任务。推进 Fake Timers 的时间通常只会让到期的计时器进入待执行状态,不一定自动执行由计时器回调创建的 Promise 微任务。测试框架提供的异步推进方法应优先使用,而不是手动猜测需要多少个 await。

JavaScript
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 微任务完成,再推进下一次计时器。一个稳妥的流程是:启动请求,等待初始微任务,推进到下一次退避时间,等待异步回调,再断言下一次调用。

JavaScript
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 通过当作真实浏览器定时精度保证:后台标签页和系统调度仍可能改变实际行为。

测试目标应是验证业务状态和停止条件,而不是验证测试框架的时钟实现。把重试策略、计时器和网络任务拆成可注入的边界,再用少量真实浏览器测试补足调度、可见性和取消行为,通常比单纯增加等待时间更可靠。

参考资料