Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Jest: Timer and Promise don't work well. (setTimeout and async function)

Any ideas on this code

jest.useFakeTimers()   it('simpleTimer', async () => {   async function simpleTimer(callback) {     await callback()    // LINE-A without await here, test works as expected.     setTimeout(() => {       simpleTimer(callback)     }, 1000)   }    const callback = jest.fn()   await simpleTimer(callback)   jest.advanceTimersByTime(8000)   expect(callback).toHaveBeenCalledTimes(9) } 

```

Failed with

Expected mock function to have been called nine times, but it was called two times. 

However, If I remove await from LINE-A, the test passes.

Does Promise and Timer not work well?

I think the reason maybe jest is waiting for second promise to resolve.

like image 589
GutenYe Avatar asked Sep 05 '18 05:09

GutenYe


People also ask

Can setTimeout take async function?

setTimeout() is an asynchronous function, meaning that the timer function will not pause execution of other functions in the functions stack. In other words, you cannot use setTimeout() to create a "pause" before the next function in the function stack fires.

Are promises better than async await?

Promise chains can become difficult to understand sometimes. Using Async/Await makes it easier to read and understand the flow of the program as compared to promise chains.

Can I use setTimeout in Jest?

The native timer functions (i.e., setTimeout() , setInterval() , clearTimeout() , clearInterval() ) are less than ideal for a testing environment since they depend on real time to elapse. Jest can swap out timers with functions that allow you to control the passage of time. Great Scott!

Do promises run asynchronously?

A promise is used to handle the asynchronous result of an operation. JavaScript is designed to not wait for an asynchronous block of code to completely execute before other synchronous parts of the code can run. With Promises, we can defer the execution of a code block until an async request is completed.


1 Answers

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 }); 
like image 159
Brian Adams Avatar answered Sep 21 '22 13:09

Brian Adams