Testing JavaScript Promises

There's not a single "promise" pun in this whole article--seriously!

Here's the situation: you're writing an ECMAScript 2015 (JavaScript) function that returns a Promise, and you want to write tests to guarantee your function-under-test either resolves or rejects the Promise as you expect. This is a little difficult because Promises are asynchronous, so the most obvious testing strategies don't work: you must be sure that your Promise is resolved in the test or else none of your assertions will run! With the Jest test runner, that's usually easy, but in this article I describe a particular quirk when you're testing Promises that don't return anything.

Consider a function that goes something like this:

function doSomeNetwork(flink) {
    return new Promise((resolve, reject) => {
        if (isValid(flink)) {
            actualNetworkCall(flink)
                .then(response => resolve())
                .catch(response => reject());
        }
        else {
            reject();
        }
    });
}

The wonderful thing about Promise-based asynchronous code is that it usually works the way it looks, even though it doesn't execute the way it looks. Here are the possible results of doSomeNetwork():

  • The "flink" argument is invalid so the Promise is rejected.
  • The actualNetworkCall() succeeds so the Promise is resolved.
  • The actualNetworkCall() fails so the Promise is reject.

The interesting part about this is what we actually want to be testing. Whether the Promise is resolved or rejected, it doesn't return anything, so we can't make assertions about the return value. What we really want to assert is that resolve() is called when the network request succeeds, and that catch() is called when the request fails.

That's easy enough, but now consider this test function:

it('rejects the Promise when the network call fails', () => {
    return doSomeNetwork(5).catch(() => {
        // ???????
    });
});

What assertion can we write in the catch() function that will cause the test to fail if the catch() function is not called? There's no way to do that! If the catch() function is not called, assertions in the catch() function will not be checked, so that's not the right way to do this.

Because you can only resolve or reject a Promise, the solution in this case is to run a negative assertion--to assert that the wrong thing did not happen. [1] For the test function above, we know that, if then() is called, the Promise was not rejected as it should have been, and therefore the test should fail. We can rewrite the test function like this:

it('rejects the Promise when the network call fails', () => {
    return doSomeNetwork(5).then(() => {
        expect('then()').toBe('never called');
    });
});

If you're like me, you're not satisfied yet. I've been bitten far too many times by JavaScript tests that look like they're testing what I need, and that Jest tells me are passing, but that don't actually run at all. Especially because I'm making a negative assertion (in the sample test, it's that "then() is not called") I want to know that either (1) then() is called, or (2) catch() is called, and (3) no other outcome is possible. I need to know for sure that this Promise is resolved!

Thankfully, that's easy. I modified my function under test so that the Promise would never resolve, then ran my test suite again. The tests froze for a few seconds, then failed. Because the Promise is returned by the test function, Jest waits for it to resolve. If the Promise times out before resolving, Jest fails that test.

So that's it! If you return a Promise from the test function, Jest makes sure it resolves. That means you can make an assertion about how the Promise is resolved by placing a guaranteed failure in the other resolution's callback.

[1]When you chain Promises, you might end up going from one then() handler to a later catch() handler if there is an unhandled error in the then() function. When this happens, control flow is always redirected to the next catch() handler in the Promise chain, potentially skipping intermediate then() handlers. It looks like the Promise is resolved then rejected, but the JavaScript runtime treats these as two separate Promises, the first of which is resolved and the second of which is rejected.