You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在Jest fakeTimers中同时推进虚拟与真实时间?

Jest Fake Timers 下同时处理虚拟时间推进与数据库持久化一致性的问题

问题场景

测试每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'] }),可行但不够优雅

核心原因

  1. Jest Fake Timers的事件循环调度干扰:
    Jest的fake timers会接管setTimeout等定时器API,将原本异步的定时器回调同步执行,但数据库I/O这类真实异步操作仍在Node.js的真实事件循环中处理。推进虚拟时间后,Jest会立刻执行所有到期的fake定时器回调,但此时数据库写入的I/O回调可能还在真实事件循环队列中等待处理,直接读取数据库会拿到未更新的旧数据。

  2. 事件循环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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.10 16:34:54