如何在Jest fakeTimers中同时推进虚拟与真实时间?
问题场景
测试每10分钟触发一次的cron任务时,需要用Jest的fake timers推进虚拟时间来触发任务调度;同时要确保TypeORM的数据库持久化操作(BaseEntity.save()/BaseEntity.reload())完成,否则读取数据库的结果会随机出错——有时能拿到正确的重调度后数据,有时不行。
当前测试核心代码:
describe('Rescheduling expired jobs', () => { beforeAll(async () => { jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); }); it('Jobs are automatically rescheduled', async () => { // 写入过期测试任务到数据库 await createExpiredTestJobs(); // 推进虚拟时间触发cron任务 await advanceTimersByTime(RESCHEDULE_EXPIRED_JOB_EVERY_MS); // 读取数据库并断言任务已重调度 const jobs = await Job.find(); expect(jobs).toEqual(expect.arrayContaining(rescheduledJobs)); }); }); export async function advanceTimersByTime(msToRun: number) { jest.advanceTimersByTime(msToRun); await flushPromises(); } function flushPromises() { return new Promise(jest.requireActual('timers').setImmediate); }
尝试过的方案:
- 添加
await sleep(1)等待真实时间,但因setTimeout被fake导致测试超时(cron依赖setTimeout,无法将其加入doNotFake列表) - 用同步循环等待真实时间流逝,需配置
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'Date'] }),可行但不够优雅
核心原因
Jest Fake Timers的事件循环调度干扰:
Jest的fake timers会接管setTimeout等定时器API,将原本异步的定时器回调同步执行,但数据库I/O这类真实异步操作仍在Node.js的真实事件循环中处理。推进虚拟时间后,Jest会立刻执行所有到期的fake定时器回调,但此时数据库写入的I/O回调可能还在真实事件循环队列中等待处理,直接读取数据库会拿到未更新的旧数据。事件循环flush不彻底:
现有flushPromises仅处理了微任务队列,但数据库I/O属于宏任务范畴,需要额外等待真实事件循环迭代才能完成处理。
正确处理方案
方案1:完善事件循环flush逻辑
修改advanceTimersByTime函数,在推进虚拟时间并flush微任务后,额外等待一次真实事件循环迭代,确保数据库I/O回调完成:
export async function advanceTimersByTime(msToRun: number) { jest.advanceTimersByTime(msToRun); // 处理虚拟时间触发的回调中的微任务 await flushPromises(); // 等待真实事件循环处理完数据库I/O等宏任务 await new Promise(resolve => jest.requireActual('timers').setImmediate(resolve)); }
方案2:在断言前单独等待真实事件循环
如果不想修改通用工具函数,也可以在读取数据库前直接添加一次真实事件循环等待:
it('Jobs are automatically rescheduled', async () => { await createExpiredTestJobs(); await advanceTimersByTime(RESCHEDULE_EXPIRED_JOB_EVERY_MS); // 等待数据库持久化完成 await new Promise(resolve => jest.requireActual('timers').setImmediate(resolve)); const jobs = await Job.find(); expect(jobs).toEqual(expect.arrayContaining(rescheduledJobs)); });
方案3:优化Jest Fake Timers配置
保留doNotFake: ['nextTick', 'setImmediate', 'Date']的配置,同时结合真实定时器的零延迟等待(避免超时问题):
// 用真实的setTimeout实现零延迟等待 const waitForRealEventLoop = () => new Promise(resolve => jest.requireActual('timers').setTimeout(resolve, 0)); it('Jobs are automatically rescheduled', async () => { await createExpiredTestJobs(); await advanceTimersByTime(RESCHEDULE_EXPIRED_JOB_EVERY_MS); await waitForRealEventLoop(); const jobs = await Job.find(); expect(jobs).toEqual(expect.arrayContaining(rescheduledJobs)); });
常见疑问解答
Jest是否有两个事件循环?
没有。Jest只有一个事件循环,fake timers只是接管了定时器的调度逻辑,将定时器回调同步执行,但真实的I/O、setImmediate等操作仍在原生事件循环中处理。问题出在虚拟时间推进后,原生事件循环的任务未被及时处理。TypeORM的save方法是否未等待持久化完成?
不是。await BaseEntity.save()会等待数据库写入操作完成后才返回Promise,问题根源是Jest的事件循环调度顺序导致读取操作在写入回调执行前触发。
内容的提问来源于stack exchange,提问作者cglacet

