
Jest Timer Mocks 完全指南用假定时器精确控制测试时间【免费下载链接】jestDelightful JavaScript Testing.项目地址: https://gitcode.com/gh_mirrors/je/jest导读在 Jest 测试中setTimeout()、setInterval()等原生定时器依赖真实时间流逝导致测试缓慢且难以断言时机相关的行为。本文基于 Jest 官方文档 docs/TimerMocks.md 与仓库源码packages/jest-fake-timers/src/modernFakeTimers.ts、packages/jest-fake-timers/src/legacyFakeTimers.ts系统讲解 Timer Mocks 的完整用法从启用/关闭假定时器到runAllTimers、runOnlyPendingTimers、advanceTimersByTime、advanceTimersToNextFrame等时间控制 API再到doNotFake选择性伪装与配置项详解。读完本文你将掌握如何在不等待真实时间的情况下精确、可重复地测试超时、轮询、动画帧等一切与时间相关的业务逻辑。为什么需要 Timer Mocks原生定时器函数setTimeout()、setInterval()、clearTimeout()、clearInterval()对于测试环境来说并不理想因为它们依赖真实时间的流逝——一次 1 秒的超时测试就要白白等待 1 秒递归定时器甚至会让测试挂起数分钟。Jest 可以将这些定时器替换为允许你控制时间流逝的函数即假定时器。从源码看这套能力被封装在独立的jest-fake-timers包中它同时提供两套实现现代实现默认基于sinonjs/fake-timers通过withGlobal(global)在任意全局对象上安装假时钟见 packages/jest-fake-timers/src/modernFakeTimers.tsLegacy 实现旧版完全由 Jest 自己维护将定时器 API 替换为 Jest mock 函数见 packages/jest-fake-timers/src/legacyFakeTimers.ts。两个实现统一由 packages/jest-fake-timers/src/index.ts 导出Jest 测试环境据此注入environment.fakeTimerslegacy与environment.fakeTimersModernmodern具体装配逻辑可参见 packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapter.ts。启用假定时器useFakeTimers 与 useRealTimers调用jest.useFakeTimers()即可启用假定时器它会替换setTimeout()及其他定时器函数的原始实现调用jest.useRealTimers()则恢复为正常行为。以下面的定时游戏模块为例它使用setTimeout在 1 秒后触发回调function timerGame(callback) { console.log(Ready....go!); setTimeout(() { console.log(Times up -- stop!); callback callback(); }, 1000); } module.exports timerGame;启用假定时器后我们可以断言setTimeout的调用情况jest.useFakeTimers(); jest.spyOn(global, setTimeout); test(waits 1 second before ending the game, () { const timerGame require(../timerGame); timerGame(); expect(setTimeout).toHaveBeenCalledTimes(1); expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 1000); });注意jest.useFakeTimers()与jest.useRealTimers()可以从顶层、test块内等任意位置调用但这是一个全局操作会影响同一文件内的其他测试。在同一文件中再次调用jest.useFakeTimers()会重置内部状态如计时器数量并按新选项重新安装假定时器。假定时器到底替换了哪些 API根据 docs/JestObjectAPI.md 与 packages/jest-types/src/Config.ts 中的FakeableAPI类型定义可被伪造的 API 包括Date、hrtime、nextTick、performance、queueMicrotask、requestAnimationFrame、cancelAnimationFrame、requestIdleCallback、cancelIdleCallback、setImmediate、clearImmediate、setInterval、clearInterval、setTimeout、clearTimeout、Temporal。具体替换范围与环境相关默认会替换Date、performance.now()、queueMicrotask()、setImmediate()、clearImmediate()、setInterval()、clearInterval()、setTimeout()、clearTimeout()在 Node 环境中还会替换process.hrtime、process.nextTick()在 jsdom 环境中还会替换requestAnimationFrame()、cancelAnimationFrame()、requestIdleCallback()、cancelIdleCallback()。在 modernFakeTimers.ts 中这些 API 会先被全部放入toFake集合再按doNotFake配置剔除。运行全部定时器jest.runAllTimers()上面的测试只验证了setTimeout被正确调度但我们还想断言回调在 1 秒后被调用。这需要借助 Jest 的时间控制 API在测试中途快进时间jest.useFakeTimers(); test(calls the callback after 1 second, () { const timerGame require(../timerGame); const callback jest.fn(); timerGame(callback); // At this point in time, the callback should not have been called yet expect(callback).not.toHaveBeenCalled(); // Fast-forward until all timers have been executed jest.runAllTimers(); // Now our callback should have been called! expect(callback).toHaveBeenCalled(); expect(callback).toHaveBeenCalledTimes(1); });jest.runAllTimers()会排空宏任务队列与微任务队列宏任务指setTimeout()、setInterval()、setImmediate()排队的任务微任务通常对应 Node 中的process.nextTick()。任务执行过程中若又调度了新任务也会被持续执行直到队列为空。从 modernFakeTimers.ts 的实现可见该方法直接委托给假时钟的this._clock.runAll()而 Legacy 实现则在 legacyFakeTimers.ts 中自行维护_timers、_ticks、_immediates三个队列并依次排空。如果测试逻辑涉及 Promise 回调可使用异步版本jest.runAllTimersAsync()它允许已调度的 Promise 回调在运行定时器之前先执行注意该 API 不适用于 legacy 实现。运行待处理定时器jest.runOnlyPendingTimers()有些场景会存在递归定时器——即定时器的回调里又设置新的定时器。对这种代码调用runAllTimers()将陷入死循环最终抛出如下错误Aborting after running 100000 timers, assuming an infinite loop!此时应使用jest.runOnlyPendingTimers()。下面的infiniteTimerGame就是典型例子——游戏结束后 10 秒又启动下一局function infiniteTimerGame(callback) { console.log(Ready....go!); setTimeout(() { console.log(Times up! 10 seconds before the next game starts...); callback callback(); // Schedule the next game in 10 seconds setTimeout(() { infiniteTimerGame(callback); }, 10000); }, 1000); } module.exports infiniteTimerGame;runOnlyPendingTimers()只排空当前时刻已存在的定时器执行过程中新创建的定时器不会在本轮被运行jest.useFakeTimers(); jest.spyOn(global, setTimeout); describe(infiniteTimerGame, () { test(schedules a 10-second timer after 1 second, () { const infiniteTimerGame require(../infiniteTimerGame); const callback jest.fn(); infiniteTimerGame(callback); // At this point in time, there should have been a single call to // setTimeout to schedule the end of the game in 1 second. expect(setTimeout).toHaveBeenCalledTimes(1); expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 1000); // Fast forward and exhaust only currently pending timers // (but not any new timers that get created during that process) jest.runOnlyPendingTimers(); // At this point, our 1-second timer should have fired its callback expect(callback).toHaveBeenCalled(); // And it should have created a new timer to start the game over in // 10 seconds expect(setTimeout).toHaveBeenCalledTimes(2); expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 10000); }); });从 Legacy 实现的源码可以看到关键设计legacyFakeTimers.ts 会先快照当前_timers的全部条目[...this._timers.entries()]再按到期时间排序逐个执行——正是为了避免执行过程中新增的定时器被顺带运行。Modern 实现则委托给假时钟的this._clock.runToLast()见 modernFakeTimers.ts。调整递归上限timerLimitrunAllTimers()默认最多运行100_000个定时器超过即抛出上述假设死循环错误。为了调试或其他目的可以通过timerLimit配置修改这个上限jest.useFakeTimers({timerLimit: 100});在 modernFakeTimers.ts 中该值被映射为假时钟的loopLimit: fakeTimersConfig.timerLimit || 100_000Legacy 实现则在构造函数中以maxLoops || 100_000方式接收见 legacyFakeTimers.ts。按时间快进jest.advanceTimersByTime() 与 clearAllTimers()另一个常用 API 是jest.advanceTimersByTime(msToRun)调用时所有定时器向前推进msToRun毫秒。在此时间窗口内所有通过setTimeout()或setInterval()排队的待执行宏任务都会被执行并且如果这些宏任务在执行过程中又调度了同一时间窗口内应执行的新宏任务它们也会被依次执行直到窗口内不再有应运行的宏任务为止。function timerGame(callback) { console.log(Ready....go!); setTimeout(() { console.log(Times up -- stop!); callback callback(); }, 1000); } module.exports timerGame;jest.useFakeTimers(); it(calls the callback after 1 second via advanceTimersByTime, () { const timerGame require(../timerGame); const callback jest.fn(); timerGame(callback); // At this point in time, the callback should not have been called yet expect(callback).not.toHaveBeenCalled(); // Fast-forward until all timers have been executed jest.advanceTimersByTime(1000); // Now our callback should have been called! expect(callback).toHaveBeenCalled(); expect(callback).toHaveBeenCalledTimes(1); });与runAllTimers()相比advanceTimersByTime()的最大价值在于精确控制推进量它只会执行落在该时间窗内的定时器适合逐步逼近某个时间点的场景。Modern 实现委托给假时钟的this._clock.tick(msToRun)见 modernFakeTimers.tsLegacy 实现则在 legacyFakeTimers.ts 中按剩余时间与下一到期时间比较逐条执行定时器最后把残余时间补到_now上。异步场景可使用jest.advanceTimersByTimeAsync(msToRun)它允许已调度的 Promise 回调先于定时器执行。另外msToRun也接受Temporal.Duration但日历单位years、months、weeks不受支持会抛错请使用基于时间的单位days、hours、minutes、seconds、milliseconds。清空所有待处理定时器某些测试中你可能需要清空所有待处理的定时器这时可以使用jest.clearAllTimers()它会把已调度但尚未执行的定时器全部移除这些定时器此后永远不会再有机会执行。其底层实现为this._clock.reset()modern见 modernFakeTimers.ts或清空_immediates与_timerslegacy见 legacyFakeTimers.ts。推进到下一动画帧jest.advanceTimersToNextFrame()在 Web 应用中经常需要借助requestAnimationFrame在动画帧内调度工作。Jest 为此提供了便捷方法jest.advanceTimersToNextFrame()它推进的毫秒数恰好足以执行所有已排队的动画帧回调。假定时器的动画帧语义时钟启动后动画帧每16ms执行一次对应约60 帧/秒。当你用requestAnimationFrame(callback)调度回调时该回调会在时钟推进 16ms 后被调用。advanceTimersToNextFrame()会把时钟推进到下一个 16ms 的整数倍时刻——例如自动画帧回调被调度以来时钟已推进了 6ms则本次调用只会再推进 10ms。jest.useFakeTimers(); it(calls the animation frame callback after advanceTimersToNextFrame(), () { const callback jest.fn(); requestAnimationFrame(callback); // At this point in time, the callback should not have been called yet expect(callback).not.toHaveBeenCalled(); jest.advanceTimersToNextFrame(); // Now our callback should have been called! expect(callback).toHaveBeenCalled(); expect(callback).toHaveBeenCalledTimes(1); });这一行为在 packages/jest-fake-timers/src/tests/modernFakeTimers.test.ts 的测试用例中被精确验证先用advanceTimersByTime(6)推进 6ms此时帧回调未执行、Date.now()等于start 6再调用advanceTimersToNextFrame()帧回调执行且时间恰好落在start 16——即只推进到下一帧所需的 10ms。该 API 在 Modern 实现中对应假时钟的this._clock.runToFrame()见 modernFakeTimers.ts。同目录测试还验证了另外两个重要语义advanceTimersToNextFrame()只运行当前已排队的帧回调帧内新调度的帧要等到下一次调用才执行见 modernFakeTimers.test.ts且支持通过cancelAnimationFrame取消已排队的帧回调见 modernFakeTimers.test.ts。Legacy 实现则不提供该 API。选择性伪造doNotFake有时你的代码需要保留某个 API 的原始实现不希望被假定时器覆盖。此时可以使用doNotFake选项。例如在 jsdom 环境下为performance.mark()提供自定义 mock 函数/** * jest-environment jsdom */ const mockPerformanceMark jest.fn(); window.performance.mark mockPerformanceMark; test(allows mocking performance.mark(), () { jest.useFakeTimers({doNotFake: [performance]}); expect(window.performance.mark).toBe(mockPerformanceMark); });doNotFake接收一个FakeableAPI名称数组如Date、nextTick、setImmediate、setTimeout等默认值为[]即默认伪造全部 API。其实现逻辑见 modernFakeTimers.ts先取出全部可伪造 API 集合再逐一删除doNotFake中列出的名称最后把剩余集合传给假时钟的toFake配置。完整的 useFakeTimers 配置项除了本文提到的timerLimit与doNotFakejest.useFakeTimers()还支持以下配置项类型定义见 packages/jest-types/src/Config.ts详细说明见 docs/JestObjectAPI.md)type FakeableAPI | Date | hrtime | nextTick | performance | queueMicrotask | requestAnimationFrame | cancelAnimationFrame | requestIdleCallback | cancelIdleCallback | setImmediate | clearImmediate | setInterval | clearInterval | setTimeout | clearTimeout | Temporal; type FakeTimersConfig { /** * 若为 true所有定时器将每隔 20ms 自动推进 20ms * 传入数字可自定义推进时间差。默认 false。 */ advanceTimers?: boolean | number; /** * 不希望被伪造的 API 名称列表。默认 []即全部伪造。 */ doNotFake?: ArrayFakeableAPI; /** * 使用旧版假定时器实现而非 sinonjs/fake-timers 实现。 * 默认 false。注意legacy 模式不支持额外选项。 */ legacyFakeTimers?: boolean; /** * 设置假定时器使用的当前系统时间。接受毫秒时间戳、Date、 * Temporal.Instant 或 Temporal.ZonedDateTime。默认 Date.now()。 */ now?: number | Date | Temporal.Instant | Temporal.ZonedDateTime; /** * 调用 jest.runAllTimers() 时允许运行的递归定时器最大数量。 * 默认 100_000。 */ timerLimit?: number; };几个实用的配置组合示例来自 docs/JestObjectAPI.md)test(advance the timers automatically, () { jest.useFakeTimers({advanceTimers: true}); // ... }); test(do not advance the timers and do not fake performance, () { jest.useFakeTimers({doNotFake: [performance]}); // ... }); test(uninstall fake timers for the rest of tests in the file, () { jest.useRealTimers(); // ... });在 modernFakeTimers.ts 中这些配置最终被转换为sinonjs/fake-timers的安装参数advanceTimers同时控制shouldAdvanceTime与advanceTimeDeltatimerLimit映射为loopLimitnow默认取Date.now()并强制shouldClearNativeTimers: true安装假定时器时清理原生定时器避免污染。在 Jest 配置中全局启用如果希望所有测试文件默认启用假定时器可以在 Jest 配置文件中设置// jest.config.js module.exports { fakeTimers: { enableGlobally: true, // 可选其他 fakeTimers 配置项 // advanceTimers: true, // doNotFake: [performance], }, };enableGlobally默认false。在 jestAdapter.ts 中可以看到当config.fakeTimers.enableGlobally为真时适配器会在测试套件 setup 阶段根据legacyFakeTimers标志调用environment.fakeTimers!.useFakeTimers()或environment.fakeTimersModern!.useFakeTimers()从而让每个测试文件在运行前就装好假定时器。Legacy 假定时器若因兼容性原因必须使用旧版假定时器实现可这样启用不支持额外选项jest.useFakeTimers({ legacyFakeTimers: true, });Legacy 假定时器会把setImmediate()、clearImmediate()、setInterval()、clearInterval()、setTimeout()、clearTimeout()替换为 Jest mock 函数在 Node 环境中还会替换process.nextTick()在 jsdom 环境中还会替换requestAnimationFrame()、cancelAnimationFrame()。Legacy 实现不提供runAllTimersAsync、advanceTimersByTimeAsync、runOnlyPendingTimersAsync、advanceTimersToNextTimerAsync、advanceTimersToNextFrame、setSystemTime、setTimerTickMode、getRealSystemTime等 API。其他实用时间控制 API围绕假定时器Jest 还提供了一组补充 API详见 docs/JestObjectAPI.md可与本文主流程配合使用jest.advanceTimersToNextTimer(steps)只推进到让最近的定时器触发的毫秒数可传入steps连续触发多个最近的定时器。Modern 实现通过循环调用this._clock.next()与this._clock.tick(0)实现见 modernFakeTimers.ts。jest.runAllTicks()排空微任务队列Node 中通常对应process.nextTick。jest.getTimerCount()返回仍未运行的假定时器数量便于在测试结束时断言没有遗留定时器。jest.setSystemTime(now?)设置假定时器使用的当前系统时间模拟用户修改系统时钟它只改变当前时间本身不会触发定时器。jest.now()返回当前假时钟的毫秒时间。jest.getRealSystemTime()当Date.now()也被伪造时用它获取真实的当前时间。小结Timer Mocks 是 Jest 测试时间敏感逻辑的基石能力。通过jest.useFakeTimers()启用假时钟后runAllTimers()适合一次性排空全部任务、runOnlyPendingTimers()适合递归定时器、advanceTimersByTime()适合按精确时间窗推进、advanceTimersToNextFrame()适合动画帧场景而doNotFake、timerLimit、now、advanceTimers等配置项则提供了细粒度的行为控制。理解其底层实现modernFakeTimers.ts 基于sinonjs/fake-timerslegacyFakeTimers.ts 为 Jest 自维护实现有助于在遇到边界行为时快速定位原因。结合仓库中的单元测试如 modernFakeTimers.test.ts你可以进一步验证每个 API 的确切语义写出既快又稳的定时器测试。【免费下载链接】jestDelightful JavaScript Testing.项目地址: https://gitcode.com/gh_mirrors/je/jest创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考