Search code examples
typescriptjestjstypeormnode-cron

Jest fakeTimers, how to advance both real and fake time?


I'm using Jest fake timers and have a test that relies on advancing both the fake time and real time. What is the correct way of doing this?

I must advance the fake time in order to trigger a cron to schedule new tasks (which should for example happen every 10 minutes). I must advance the real time because I'm persisting things to the database.

The test looks like this:

describe('Rescheduling expired jobs', () => {
    beforeAll(async () => {
        jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
    });

    it('Jobs are automatically rescheduled', async () => {
        // ... Write expired test jobs in the database
        await advanceTimersByTime(RESCHEDULE_EXPIRED_JOB_EVERY_MS);
        // ... Read database jobs and expect them to be rescheduled
    });
});

export async function advanceTimersByTime(msToRun: number) {
    jest.advanceTimersByTime(msToRun);
    await flushPromises();
}

// patch from github.com/jestjs/jest/issues/2157#issuecomment-897935688
function flushPromises() {
    return new Promise(jest.requireActual('timers').setImmediate);
}

Remarks:

  • Database writes are done using typeorm's await BaseEntity.save().
  • Database reads are done using typeorm's await BaseEntity.reload().

But when I read data from the database, I sometimes get the correct state, some other time I don't (its more or less random). At first I though I was missing an await somewhere, but then I double checked all my database writes and they were all good. So I guess Jest is doing some strange stuff and actually have two event loops (one for the tests and one for the application code?). Anyway, waiting for real time to advance seemed to be the only way to get this to work so I first tried this:

// ... Write expired test jobs in the database
await advanceTimersByTime(RESCHEDULE_EXPIRED_JOB_EVERY_MS);
await sleep(1);
// ... Read database jobs and expect them to be rescheduled

With sleep defined as such:

async function sleep(ms: number){
    await new Promise((res) => setTimeout(res, ms));
};

This failed. Every time sleep is called in a test, the test gets a timeout error. I think its because useFakeTimers do fake setTimeout which makes this attempt at advancing real time worthless (and I can't add it to the doNotFake list because of the cron).

So I decided to just replace the asynchronous sleep function by a synchronous elapseRealTime function call that simply loops over and over until the time is right:

function elapseRealTime(ms: number) {
    const start = new Date().getTime();
    for (let _ = 0; _ < 1e7; _++) {
        if (new Date().getTime() - start > ms) {
            break;
        }
    }
}

That solution works but seems a little far-fetched to me. I must have misunderstood something, or missing something because I don't know much about jest. Note that using this requires to not mock Date:

jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'Date'] });

What is the correct way of dealing with this? Why is jest spawning two even loops (is it really)? Or maybe there is a bug in typeorm's save that doesn't actually wait for data to be persisted?

Any help/suggestion is welcome.


Solution

  • I found a way, that is also dirty, but I find it a little bit less dirty. It seems like setInterval is not internally using setTimeout therefore I can exclude setInterval from the mock list:

    jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'setInterval'] });
    

    Which I can then use to build a sleep function:

    async function sleep(ms: number) {
        let intervalId: number;
        return new Promise<void>((resolve) => {
            intervalId = setInterval(async () => {
                resolve();
                clearInterval(intervalId); // Cancels itself on first run => mimic setTimeout
            }, ms);
        });
    }
    

    At least now it's not an active "pause", but I'm lucky that the cron doesn't seem to rely on setInterval to function properly.

    TSPlayground code