Skip to content

Instantly share code, notes, and snippets.

@apieceofbart
Last active January 18, 2024 17:14
Show Gist options
  • 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)
})
@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