-
-
Save apieceofbart/e6dea8d884d29cf88cdb54ef14ddbcc4 to your computer and use it in GitHub Desktop.
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 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
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);
});
@codeandcats Thank you very much for your solution of saving the original process.nextTick
, it worked perfectly!
@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
@apieceofbart Bless you for this!! 🙏
@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
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);
});
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, [])
}
@apieceofbart thank you so much for this, was stuck until I found this!