You might have run into situations where you're calling asynchronous code inside
of a callback of some framework, and you need to test their side effects. For
example, you might be making API calls inside of a React component's
componentDidMount()
callback that will in turn call setState()
when the
request has completed, and you might want to assert that the component is in a
certain state. This article shows techniques for testing these types of
scenarios.
Take a simplified example. We have a class called
PromisesHaveFinishedIndicator
. The constructor takes in a list of promises.
When all of the promises have resolved, the instance's finished
property is
set to true
:
{ thisfinished = false; Promiseallpromises; }
A good test case would involve calling the constructor with multiple promises
whose resolution timing we can control, and writing expectations of the value of
this.finished
as each promise is resolved.
In order to control resolution timings of promises in tests, we use Deferred
objects which expose the resolve
and reject
methods:
{ thispromise = { thisresolve = resolve; thisreject = reject; }; }
With this, we can set up a test for PromisesHaveFinishedIndicator
. We use the
Jest testing framework in this example, but the technique can be applied to
other testing frameworks as well:
;
If you tried running this test it will actually fail because promise callbacks
are asynchronous. In other words, the promise's onFulfilled
callback, the one
setting this.finished = true
, will be queued and ran after the last statement
of this test due to run to completion semantics.
Jest (and other testing frameworks) provides a way to deal with asynchrony by
preventing the test from exiting after the last statement. We would have to call
the provided done
function in order to tell the runner that the test has
finished. Now you may think something like this would work:
;
However this will also fail. The reason lies in the implementation of
Promise.all
which can be thought of to look something like this:
Promise { const numPromises = promiseslength; const results = ; let numFulfilled = 0; return { promises; };};
When resolve
is called on d1
(and d2
as well), the
implementation of Promise.all
schedules an onFulfilled
callback that checks
whether all promises have resolved. If this check returns true, it will resolve
the promise returned from the Promise.all
call which would then enqueue the () => { this.finished = true; }
callback. This callback is still sitting in the queue
by the time done
is called!
Now the question is how do we make the callback that sets this.finished
to
true
to run before calling done
? To answer this we need to understand how
promise callbacks are scheduled when promises are resolved or rejected. Jake
Archibald's article on Tasks, microtasks, queues and schedules goes in
depth on exactly that topic, and I highly recommend reading it.
In summary: Promise callbacks are queued onto the microtask queue and
callbacks of APIs such as setTimeout(fn)
and setInterval(fn)
are queued
onto the macrotask queue. Callbacks sitting on the microtask queue are run right
after the stack empties out, and if a microtask schedules another microtask,
then they will continually be pulled off the queue before yielding to the
macrotask queue.
With this knowledge, we can make this test pass by using setTimeout
instead of
then()
:
;
The reason this works is because by the time second setTimeout
callback runs,
we know that these promise callbacks have run:
- The callbacks attached to
d1.then
andd2.then
by the implementation ofPromise.all
. - The callback that sets
this.finished = true
.
Having a bunch of setTimeout(fn, 0)
in our code is unsightly to say the least.
We can clean this up with the new async/await
syntax:
{ return ;} ;
If you want to be extra fancy, you can use setImmediate
instead of
setTimeout
in some environments (Node.js). It is faster than setTimeout
but
still runs after microtasks:
{ return ;}
I have published the flushPromises
function as the flush-promises
package on npm.
When writing tests involving promises and asynchrony, it is beneficial to understand how callbacks are scheduled and the roles that different queues play on the event loop. Having this knowledge allows us to reason with asynchrounous the code that we write.