-
-
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