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)
})
@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