-
-
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) | |
}) | |
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, [])
}
@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