Skip to content

Instantly share code, notes, and snippets.

@apieceofbart
Last active January 18, 2024 17:14
Show Gist options
  • Star 45 You must be signed in to star a gist
  • Fork 4 You must be signed in to fork a gist
  • Save apieceofbart/e6dea8d884d29cf88cdb54ef14ddbcc4 to your computer and use it in GitHub Desktop.
Save apieceofbart/e6dea8d884d29cf88cdb54ef14ddbcc4 to your computer and use it in GitHub Desktop.
Async testing with jest fake timers and promises
PLEASE CHECK THIS REPO WITH THE EXAMPLES THAT YOU CAN RUN:
https://github.com/apieceofbart/async-testing-with-jest-fake-timers-and-promises
// Let's say you have a function that does some async operation inside setTimeout (think of polling for data)
function runInterval(callback, interval = 1000) {
setInterval(async () => {
const results = await Promise.resolve(42) // this might fetch some data from server
callback(results)
}, interval)
}
// Goal: We want to test that function - make sure our callback was called
// The easiest way would be to pause inside test for as long as we neeed:
const pause = ms => new Promise(res => setTimeout(res, ms))
it('should call callback', async () => {
const mockCallback = jest.fn()
runInterval(mockCallback)
await pause(1000)
expect(mockCallback).toHaveBeenCalledTimes(1)
})
// This works but it sucks we have to wait 1 sec for this test to pass
// We can use jest fake timers to speed up the timeout
it('should call callback', () => { // no longer async
jest.useFakeTimers()
const mockCallback = jest.fn()
runInterval(mockCallback)
jest.advanceTimersByTime(1000)
expect(mockCallback).toHaveBeenCalledTimes(1)
})
// This won't work - jest fake timers do not work well with promises.
// If our runInterval function didn't have a promise inside that would be fine:
function runInterval(callback, interval = 1000) {
setInterval(() => {
callback()
}, interval)
}
it('should call callback', () => {
jest.useFakeTimers()
const mockCallback = jest.fn()
runInterval(mockCallback)
jest.advanceTimersByTime(1000)
expect(mockCallback).toHaveBeenCalledTimes(1) // works!
})
// What we need to do is to have some way to resolve the pending promises. One way to do it is to use process.nextTick:
const flushPromises = () => new Promise(res => process.nextTick(res))
// IF YOU'RE USING NEW JEST (>27) WITH MODERN TIMERS YOU HAVE TO USE A SLIGHTLY DIFFERENT VERSION
// const flushPromises = () => new Promise(jest.requireActual("timers").setImmediate)
it('should call callback', async () => {
jest.useFakeTimers()
const mockCallback = jest.fn()
runInterval(mockCallback)
jest.advanceTimersByTime(1000)
await flushPromises()
expect(mockCallback).toHaveBeenCalledTimes(1)
})
@gnprice
Copy link

gnprice commented Jan 6, 2022

Thanks for writing down these examples! In case it's helpful for anyone else who comes across this (I found it on a web search for [jest nexttick]):

  • As written, if you run this file in Jest the tests all pass. That's because the second function runInterval is shadowing the first one.
    • Instead, if you name the second one like function runIntervalWithoutPromises and make the third test refer to that one, you get the expected results: the second test fails and the others pass.
  • Instead of await flushPromises(), a simpler version is await Promise.resolve(). Or even await null.
    • But! If the code under test has a longer chain of Promises:
    const results = await Promise.resolve(42); // this might fetch some data from server
    const r2 = await Promise.resolve(results);
    callback(r2);

then something like await Promise.resolve() only works if you do it repeatedly. Whereas the await new Promise(process.nextTick) solution in this gist keeps working.

@gnprice
Copy link

gnprice commented Jan 6, 2022

Oh, also: this only works with Jest's "legacy" fake timers. With "modern" fake timers -- the default in recent Jest -- process.nextTick is also faked out, so that its callback waits for something like jest.advanceTimersByTime in the same way that setTimeout does. For example:

  jest.useFakeTimers('modern');
  const p = new Promise(r => process.nextTick(() => { console.log('B'); r(); }));
  console.log('A');
  //   await p; // this will hang
  jest.advanceTimersByTime(1);
  console.log('C');
  await p; // this is fine
  console.log('D');

In that context, the only dependable solution I know of is to have a Promise you can await on that's at the end of the chain. For example:

it('should call callback', async () => {
  jest.useFakeTimers('modern');
  const p = new Promise(resolve =>
    runInterval(result => {
      expect(result).toEqual(42);
      resolve();
    }),
  );
  jest.advanceTimersByTime(1000);
  await p;
});

That works great for confirming a callback does get called, and called with the arguments you expected etc.

I don't know a reliable way for confirming a callback doesn't get called -- for example confirming that the mockCallback in the tests in this gist will be called only once in that first 1000ms, and not more times than that. The best I have is to repeat await null (or await Promise.resolve() a bunch of times in a loop, and hope there isn't a lingering chain longer than that:

const flushPromises = async () => {
  for (let i = 100; i > 0; i--) { // Hope this arbitrary number is enough!
    await null;
  }
};

it('should call callback only once', async () => {
  jest.useFakeTimers('modern');
  const mockCallback = jest.fn();

  runInterval(mockCallback);

  jest.advanceTimersByTime(1000);
  await flushPromises();
  expect(mockCallback).toHaveBeenCalledTimes(1);
});

@apieceofbart
Copy link
Author

@gnprice thank you so much for the comments!
I wrote it mostly to myself some time ago to be honest but I'm happy it's of any help to people.
Obviously I used runInterval twice just to illustrate an example, as you pointed out it won't make sense running all the code without editing.

As for the new version of timers - I have to revisit this and maybe edit the post? Feels like it should be a blog post or something :)

@namansukhwani
Copy link

Thanks man this was a great help!!

@codeandcats
Copy link

codeandcats commented Jul 23, 2022

@apieceofbart any luck getting this to work nicely with modern timers? I’m really struggling

Update:
It seems the call to process.nextTick does not work with modern timers because jest also fakes out process.nextTick. Funnily enough, even though jest's modern timers implementation uses @sinonjs/fake-timers, sinon's implementation doesn't fake out process.nextTick (for good reason) and so far I've found that simply using sinon instead of jest for faking time solves my problems.

@Arrow7000
Copy link

I've been struggling with this also. I'm trying to test my library qew. The library queues tasks using setTimeout and Promises. I'm finding it impossible to get jest to run more than one promise after another. None of the tricks to 'flush the microtask queue' mentioned either here or in other threads work. The tests fail consistently 100% of the time. Even though when I actually run the code normally everything works absolutely fine. I'm pulling my hair out how it seems to be impossible to get jest to just do what it says on the tin: run some fake timers and resolve some promises in the process.

@codeandcats
Copy link

@Arrow7000 Yeah I 1000% recommend just not using jest for mocking time and use Sinon. You can keep using Jest for test assertions since it does everything else super well. As soon I moved to Sinon for mocking time all my tests started passing.

@Arrow7000
Copy link

Ah so I can still use jest as the actual test runner and for assertions and only use sinon to stub out the timer? That would make things a lot easier than ripping jest out completely

@apieceofbart
Copy link
Author

Hey everyone!
I'm going to try to update this (and setup a repo with examples we could run) to work with modern jest timers - stay tuned!

@apieceofbart
Copy link
Author

apieceofbart commented Jul 27, 2022

After bit of googling what I think works for new jest timers is this:

function flushPromises() {
  return new Promise(jest.requireActual("timers").setImmediate);
}

I have created a repo where you can run tests: https://github.com/apieceofbart/async-testing-with-jest-fake-timers-and-promises

@Arrow7000
Copy link

@codeandcats THANK YOU for the recommendation to use Sinon's timers with Jest! My tests finally work! And it was really easy to just use Sinon for fake timers and keep Jest for everything else! After many years my one NPM package now finally has tests 🥳

@Arrow7000
Copy link

@apieceofbart I tried your flushPromises function but unfortunately that didn't work for me either

@codeandcats
Copy link

codeandcats commented Jul 29, 2022

@Arrow7000 I was inspired by @apieceofbart and was able to get my tests working perfectly using jest's fake timers by simply capturing the original process.nextTick before the call to jest.useFakeTimers()

const originalProcessNextTick = process.nextTick

const flushPromises = new Promise(resolve => originalProcessNextTick(resolve))
import { flushPromises } from './flushPromises'

describe('my time-based tests', () => {
  beforeEach(() => jest.useFakeTimers())
  afterEach(() => jest.useRealTimers())

  ...
})

@apieceofbart If you could update your gist so other people using moderns timers can benefit from it that would be amazing. Thanks for the gist. ❤️

@apieceofbart
Copy link
Author

@Arrow7000 hey, maybe you could share a minimal repro case and perhaps there's a way to fix this? This way others may benefit as well.

@Arrow7000
Copy link

@apieceofbart repro case of the test that I did get working or of the approach using flushPromises that still didn't work for me?

@ryanulit
Copy link

@apieceofbart thank you so much for this, was stuck until I found this!

@apieceofbart
Copy link
Author

@Arrow7000 sorry for being late - ideally both! Some minimal test case that did not work and what you did to make it work would be beneficial for everyone

@FanchenBao
Copy link

FanchenBao commented Aug 30, 2022

Unfortunately, the example in the gist doesn't work for me.

function runInterval(callback, interval = 1000) {
  setInterval(async () => {
    const results = await Promise.resolve(42); // this might fetch some data from server
    callback(results);
  }, interval);
}

const flushPromises = () => new Promise(res => process.nextTick(res));

it('should call callback', async () => {
  jest.useFakeTimers('legacy');
  const mockCallback = jest.fn();

  runInterval(mockCallback);

  jest.advanceTimersByTime(1000);
  await flushPromises();
  expect(mockCallback).toHaveBeenCalledTimes(1); // does NOT work
});

What eventually worked was following the recommendation from @codeandcats to switch to @sinonjs/fake-timers

import FakeTimers from '@sinonjs/fake-timers';

function runInterval(callback, interval = 1000) {
  setInterval(async () => {
    const results = await Promise.resolve(42); // this might fetch some data from server
    callback(results);
  }, interval);
}

it.only('should call callback', async () => {
  const clock = FakeTimers.install();
  const mockCallback = jest.fn();

  runInterval(mockCallback);

  await clock.tickAsync(1000);
  expect(mockCallback).toHaveBeenCalledTimes(1);
});

@Marinell0
Copy link

@codeandcats Thank you very much for your solution of saving the original process.nextTick, it worked perfectly!

@codeandcats
Copy link

@Marinell0 you’re welcome. For everyone else, there’s no need to use sinon - just capture the original process.nextTick reference before jest overwrites it and use it

@heylookltsme
Copy link

@apieceofbart Bless you for this!! 🙏

@ramblingenzyme
Copy link

@apieceofbart I'm not sure if this was added after the modern timer implementation, but Jest itself includes jest.runAllTicks, which should do the same thing. https://jestjs.io/docs/jest-object#jestrunallticks

@albanx
Copy link

albanx commented Jul 18, 2023

I tried all the above and could not get it worked, I have a nested test case with 2 promises and different timeouts. The only solution that worked best for me was:

      jest.spyOn(global, "setTimeout").mockImplementation((fn) => {
        fn();
        return setTimeout(() => 1, 0);
      });

@blwinters
Copy link

I'm using Jest v29 and useFakeTimers now allows us to specify what not to fake, e.g. jest.useFakeTimers({ doNotFake: ['nextTick'] }).

I'm testing a function that batches an array of network requests (fetchChatSessionToken()) into groups of 5 and performs each batch inside a Promise.all(). It then uses setTimeout for a 1 second delay to address rate limiting, before calling the next batch recursively. So in the below test case of 12 requests, there are 3 batches split up by two timeouts.

I found that I had to call both await new Promise(process.nextTick) and jest.advanceTimersByTime(1000) each time I wanted to advance to the next batch, so twice for a test with 3 expected batches. Also note that the test returns an expect(promise).resolves..., which may affect the number of nextTick events that you need.

  describe('given 12 orgIds', () => {
    const orgIds = ['org1', 'org2', 'org3', 'org4', 'org5', 'org6', 'org7', 'org8', 'org9', 'org10', 'org11', 'org12']

    beforeEach(() => {
      jest.useFakeTimers({ doNotFake: ['nextTick'] })
      jest
        .spyOn(MembersHooks, 'fetchChatSessionToken')
        .mockImplementation(async ({ organizationId }) => makeOrgChatToken(organizationId))
    })

    afterAll(() => {
      jest.useRealTimers()
    })

    it('returns 12 OrganizationChatTokens', async () => {
      const expectedTokens = orgIds.map(orgId => makeOrgChatToken(orgId))
      const promise = SessionRegistration._batchFetchOrgChatTokens(orgIds)
      await new Promise(process.nextTick)
      jest.advanceTimersByTime(1000)
      await new Promise(process.nextTick)
      jest.advanceTimersByTime(1000)
      return expect(promise).resolves.toEqual(expectedTokens)
    })
  })

// implementation
export const _batchFetchOrgChatTokens = async (orgIds: string[]): Promise<OrganizationChatToken[]> => {
  const batchSize = 5
  const delayMs = 1000

  const recursivelyFetchTokenBatch = async (
    queue: string[],
    tokenAccumulator: OrganizationChatToken[],
  ): Promise<OrganizationChatToken[]> => {
    const batch = queue.slice(0, batchSize)
    const remainder = queue.slice(batchSize)
    return Promise.all(
      batch.map(async orgId => {
        return fetchChatSessionToken({ organizationId: orgId })
      }),
    ).then(async newTokens => {
      const combinedTokens = tokenAccumulator.concat(newTokens)
      if (remainder.length) {
        console.info(`Waiting 1 second before fetching ${remainder.length} more chat session tokens`)
        return delay(delayMs).then(() => recursivelyFetchTokenBatch(remainder, combinedTokens))
      } else {
        return Promise.resolve(combinedTokens)
      }
    })
  }
  return recursivelyFetchTokenBatch(orgIds, [])
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment