Jest: Timer and Promise don't work well. (setTimeout and async function)
Yes, you're on the right track.
What happens
await simpleTimer(callback)
will wait for the Promise returned by simpleTimer()
to resolve so callback()
gets called the first time and setTimeout()
also gets called. jest.useFakeTimers()
replaced setTimeout()
with a mock so the mock records that it was called with [ () => { simpleTimer(callback) }, 1000 ]
.
jest.advanceTimersByTime(8000)
runs () => { simpleTimer(callback) }
(since 1000 < 8000) which calls setTimer(callback)
which calls callback()
the second time and returns the Promise created by await
. setTimeout()
does not run a second time since the rest of setTimer(callback)
is queued in the PromiseJobs
queue and has not had a chance to run.
expect(callback).toHaveBeenCalledTimes(9)
fails reporting that callback()
was only called twice.
Additional Information
This is a good question. It draws attention to some unique characteristics of JavaScript and how it works under the hood.
Message Queue
JavaScript uses a message queue. Each message is run to completion before the runtime returns to the queue to retrieve the next message. Functions like setTimeout()
add messages to the queue.
Job Queues
ES6 introduces Job Queues
and one of the required job queues is PromiseJobs
which handles "Jobs that are responses to the settlement of a Promise". Any jobs in this queue run after the current message completes and before the next message begins.
then()
queues a job in PromiseJobs
when the Promise it is called on resolves.
async / await
async / await
is just syntactic sugar over promises and generators. async
always returns a Promise and await
essentially wraps the rest of the function in a then
callback attached to the Promise it is given.
Timer Mocks
Timer Mocks work by replacing functions like setTimeout()
with mocks when jest.useFakeTimers()
is called. These mocks record the arguments they were called with. Then when jest.advanceTimersByTime()
is called a loop runs that synchronously calls any callbacks that would have been scheduled in the elapsed time, including any that get added while running the callbacks.
In other words, setTimeout()
normally queues messages that must wait until the current message completes before they can run. Timer Mocks allow the callbacks to be run synchronously within the current message.
Here is an example that demonstrates the above information:
jest.useFakeTimers();
test('execution order', async () => {
const order = [];
order.push('1');
setTimeout(() => { order.push('6'); }, 0);
const promise = new Promise(resolve => {
order.push('2');
resolve();
}).then(() => {
order.push('4');
});
order.push('3');
await promise;
order.push('5');
jest.advanceTimersByTime(0);
expect(order).toEqual([ '1', '2', '3', '4', '5', '6' ]);
});
How to get Timer Mocks and Promises to play nice
Timer Mocks will execute the callbacks synchronously, but those callbacks may cause jobs to be queued in PromiseJobs
.
Fortunately it is actually quite easy to let all pending jobs in PromiseJobs
run within an async
test, all you need to do is call await Promise.resolve()
. This will essentially queue the remainder of the test at the end of the PromiseJobs
queue and let everything already in the queue run first.
With that in mind, here is a working version of the test:
jest.useFakeTimers()
it('simpleTimer', async () => {
async function simpleTimer(callback) {
await callback();
setTimeout(() => {
simpleTimer(callback);
}, 1000);
}
const callback = jest.fn();
await simpleTimer(callback);
for(let i = 0; i < 8; i++) {
jest.advanceTimersByTime(1000);
await Promise.resolve(); // allow any pending jobs in the PromiseJobs queue to run
}
expect(callback).toHaveBeenCalledTimes(9); // SUCCESS
});
setTimeout with Promise wrapper not working as expected with Jest async/await
You just need to pass mockCallback
as the first argument to setTimeout
:
const sleep = ms => new Promise(res => setTimeout(res, ms));
it('should call callback after specified delay', async () => {
const mockCallback = jest.fn();
setTimeout(mockCallback, 1000); // <= pass mockCallback as first argument
expect(mockCallback).not.toHaveBeenCalled(); // Success!
await sleep(1000);
expect(mockCallback).toHaveBeenCalled(); // Success!
});
How to properly use Promises and Timers with Jest
So with a follow up comment from @Bergi, I relaized the done
wasn't actually necessary either. I just needed to re-order some things. I then ran into a similar issue when testing chains of promises that further highlighted this so I added some cases for that.
jest.useFakeTimers();
describe('timers test', () => {
it('Using a plain timer works as expected', () => {
const mock = jest.fn();
setTimeout(mock, 5000);
jest.runAllTimers();
expect(mock).toHaveBeenCalled();
});
it('Using a timer to mock a promise resolution', async () => {
const mock = jest.fn(() => {
return new Promise((resolve) => setTimeout(resolve, 500));
});
const handler = jest.fn();
const actual = mock().then(handler);
jest.runAllTimers();
await actual;
expect(handler).toHaveBeenCalled();
});
it('Using a timer to mock a promise rejection', async () => {
const mock = jest.fn(() => {
return new Promise((resolve, reject) => setTimeout(reject, 500));
});
const handler = jest.fn();
const actual = mock().catch(handler);
jest.runAllTimers();
await actual;
expect(handler).toHaveBeenCalled();
});
it('Using a timer to mock a promise resolve -> delay -> resolve chain', async () => {
const mockA = jest.fn(() => {
return Promise.resolve();
});
const mockB = jest.fn(() => {
return new Promise((resolve, reject) => {
setTimeout(resolve, 500);
});
});
const handler = jest.fn();
const actual = mockA()
.then(() => {
const mockProm = mockB();
jest.runAllTimers();
return mockProm;
})
.then(handler);
jest.runAllTimers();
await actual;
expect(mockA).toHaveBeenCalled();
expect(mockB).toHaveBeenCalled();
expect(handler).toHaveBeenCalled();
});
it('Using a timer to mock a promise resolve -> delay -> reject chain', async () => {
const mockA = jest.fn(() => {
return Promise.resolve();
});
const mockB = jest.fn(() => {
return new Promise((resolve, reject) => {
setTimeout(reject, 500);
});
});
const handler = jest.fn();
const actual = mockA()
.then(() => {
const mockProm = mockB();
jest.runAllTimers();
return mockProm;
})
.catch(handler);
await actual;
expect(mockA).toHaveBeenCalled();
expect(mockB).toHaveBeenCalled();
expect(handler).toHaveBeenCalled();
});
});
@Bergi' comment led me to the solution. I ended up making use of the done
function, and removing the await
. This seems to work at least in this minimal test case.
jest.useFakeTimers();
describe('timers test', () => {
it('plain timer works as expected', () => {
const mock = jest.fn();
setTimeout(mock, 5000);
jest.runAllTimers();
expect(mock).toHaveBeenCalled();
});
it('Using a timer to mock a promise resolution results in a jest timeout error', async (done) => {
const mock = jest.fn().mockImplementation(() => {
return new Promise((resolve) => setTimeout(resolve, 500));
});
// make the handler invoke done to replace the await
const handler = jest.fn(done);
mock().then(handler);
jest.runAllTimers();
expect(handler).toHaveBeenCalled();
});
it('Using a timer to mock a promise rejection results in a jest timeout error', async (done) => {
const mock = jest.fn().mockImplementation(() => {
return new Promise((resolve, reject) => setTimeout(reject, 500));
});
// make the handler invoke done to replace the await
const handler = jest.fn(done);
mock().catch(handler);
jest.runAllTimers();
expect(handler).toHaveBeenCalled();
});
});
Jest fake timers with promises
The current best alternative is to use the async versions of fake-timers. So you would do
await clock.tickAsync(1000); // doesn't wait 1000ms but is async
Instead of calling clock.tick
. Please see the answer below for more details.
At the moment, it's not supported
You're not doing anything wrong - it doesn't work at the moment - sorry. The following things have to happen before this will work from our end:
- Jest needs to merge the ongoing work to merge lolex as their fake timer implementation here https://github.com/facebook/jest/pull/5171
- Lolex needs to support pumping through promises - we've discussed this with the V8 team in a recent Node.js collaborator summit. That would expose a hook we'll use to allow doing something like
advanceTimeByTime(100)
and have that work with promises.
The problem in a gist is that the .then(spy)
only gets called later.
As we are volunteers - there is no concrete timeline for these things. I hope SimenB does the merge in the coming 2-3 months and I'll follow up with the hook with the V8 team next month.
What you can do now
You can always write an async test:
// note this is an async function now
it('resolves in a given amount of time', async () => {
// this is in a promise.reoslve.then to not 'lock' on the await
Promise.resolve().then(() => jest.advanceTimersByTime(100));
await timeout(100);
});
You can add expectations after the timeout if there is anything else you want to wait for.
Jest unit test - How do I call through async function in a setTimeout repeating function
So it sounds like I have to...resolve some pending promise here?
Yes, exactly.
The short answer is that a Promise
callback gets queued in PromiseJobs by the then
chained to the Promise
returned by doAsyncStuff
, and the way the test is written, that callback never has a chance to run until the test is already over.
To fix it, give the Promise
callbacks a chance to run during your test:
updater.js
export const doAsyncStuff = async () => { };
code.js
import { doAsyncStuff } from './updater';
export function repeatMe() {
setTimeout(() => {
doAsyncStuff().then((response) => {
if (response) {
console.log("I get here!");
repeatMe();
}
})
}, 5000);
}
code.test.js
import * as updater from './updater';
import { repeatMe } from './code';
test('repeatMe', async () => {
jest.useFakeTimers();
let doAsyncStuff = jest.spyOn(updater, 'doAsyncStuff');
doAsyncStuff.mockResolvedValue(true);
repeatMe();
jest.advanceTimersByTime(5000);
expect(doAsyncStuff).toHaveBeenCalledTimes(1); // Success!
await Promise.resolve(); // let callbacks in PromiseJobs run
jest.advanceTimersByTime(5000);
expect(doAsyncStuff).toHaveBeenCalledTimes(2); // Success!
await Promise.resolve(); // let callbacks in PromiseJobs run
jest.advanceTimersByTime(5000);
expect(doAsyncStuff).toHaveBeenCalledTimes(3); // Success!
// ... and so on ...
});
The complete details of exactly what happens and why can be found in my answer here
How to mock an async function's delay time using Jest
Mock funcA
to return a deferred promise, to be resolved later. I know sinon provides a promise helper to cover deferred so jest may include a similar construct. Otherwise here is one of the simple implementations from that answer:
class Deferred {
constructor() {
this.promise = new Promise((resolve, reject) => {
this.reject = reject
this.resolve = resolve
})
}
}
Then the mock is something like:
const deferred = new Deferred()
const spyOnFuncA = jest.spyOn(module2, 'funcA').mockImplementation(() => deferred.promise)
mainFunc() // avoid uncaught exceptions with `.catch`
.catch(err => expect(err).toBe(null))
expect(spyOnFuncA).toBeCalled()
expect(spyOnFuncB).not.toBeCalled()
await deferred.resolve('whatever')
expect(spyOnFuncB).toBeCalled()
Related Topics
How to Use Redis Publish/Subscribe with Nodejs to Notify Clients When Data Values Change
How to Update React Context from Inside a Child Component
How to Read Get Data from a Url Using JavaScript
How to Use Cors to Implement JavaScript Google Places API Request
Stop Cursor from Jumping to End of Input Field in JavaScript Replace
Is It a Good Idea to Learn JavaScript Before Learning Jquery
How to Round Float Numbers in JavaScript
Convert a Directory Structure in the Filesystem to JSON with Node.Js
How to Import Image (.Svg, .Png ) in a React Component
$Http.Get(...).Success Is Not a Function
How to Remove an Object from an Array with JavaScript
Why JavaScript Function Declaration (And Expression)
Wrapping a Set of Dom Elements Using JavaScript
Why JavaScript Function Declaration (And Expression)