Testing Promise Side Effects with Async/Await

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

class PromisesHaveFinishedIndicator {
 constructor(promises) {
   this.finished = false;
   Promise.all(promises).then(() => {
     this.finished = true;
   });
 }
}

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:

class Deferred {
 constructor() {
   this.promise = new Promise((resolve, reject) => {
     this.resolve = resolve;
     this.reject = 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:

test('sets finished to true after all promises have resolved', () => {
 const d1 = new Deferred();
 const d2 = new Deferred();
 const indicator = new PromisesHaveFinishedIndicator([d1.promise, d2.promise]);
 expect(indicator.finished).toBe(false);
 d2.resolve();
 expect(indicator.finished).toBe(false);
 d1.resolve();
 expect(indicator.finished).toBe(true);
});

This test will actually fail because promise callbacks are asynchronous, so any callbacks sitting in the queue will run after the last statement of this test due to run to completion semantics. In other words the promise callback for the Promise.all call: () => { this.finished = true; } will have run after this test has already exited!
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:

test('sets finished to true after all promises have resolved', (done) => {
 const d1 = new Deferred();
 const d2 = new Deferred();
 const indicator = new PromisesHaveFinishedIndicator([d1.promise, d2.promise]);
 expect(indicator.finished).toBe(false);
 d2.resolve();
 d2.then(() => {
   expect(indicator.finished).toBe(false);
   d1.resolve();
   d1.then(() => {
     expect(indicator.finished).toBe(true);
     done();
   });
 });
});

However this will also fail. The reason lies in the implementation of Promise.all. When resolve is called on d1 (and d2 as well), Promise.all schedules a 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():

test('sets finished to true after all promises have resolved', (done) => {
 const d1 = new Deferred();
 const d2 = new Deferred();
 const indicator = new PromisesHaveFinishedIndicator([d1.promise, d2.promise]);
 expect(indicator.finished).toBe(false);
 d2.resolve();
 setTimeout(() => {
   expect(indicator.finished).toBe(false);
   d1.resolve();
   setTimeout(() => {
     expect(indicator.finished).toBe(true);
     done();
   }, 0);
 }, 0);
});

The reason this works is that by the time second setTimeout callback runs, we know that these promise callbacks have run:

  • The callback inside the implementation of Promise.all that checks that all promises have resolved, then resolves the returned promise.
  • 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:

function flushPromises() {
 return new Promise((resolve, reject) => setTimeout(resolve, 0));
}
test('sets finished to true after all promises have resolved', async () => {
 const d1 = new Deferred();
 const d2 = new Deferred();
 const indicator = new PromisesHaveFinishedIndicator([d1.promise, d2.promise]);
 expect(indicator.finished).toBe(false);
 d2.resolve();
 await flushPromises();
 expect(indicator.finished).toBe(false);
 d1.resolve();
 await flushPromises();
 expect(indicator.finished).toBe(true);
});

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:

function flushPromises() {
 return new Promise(resolve => setImmediate(resolve));
}

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 the asynchronous code that we write.

Similar Posts