How to test asynchronous errors with Mocha
It’s often the case in the everyday work of a developer that a function returning a Promise needs to be tested.
It’s usually not an issue to test results when the promise resolves. Testing errors returned by the async function can be a little bit tricky, though.
Example function
Let’s create a dead simple example function and see how we can test both the happy (when the promise resolves) and the sad outcome (i.e. when an error is thrown and the promise is rejected).
I created a file called index.js
in the project folder and put the following code in the file:
// index.js
const asyncAddFive = (num) => {
if (isNaN(num)) {
return Promise.reject(new Error('Argument must be a number'))
}
return Promise.resolve(`The returned value is ${ num + 5 }`)
}
module.exports = asyncAddFive
As I said this function will probably not going to change how you think about the world, but it will do for now.
The function adds 5 to any number that is fed into it, and returns a string with the result. If the argument is not a number, the function returns an error with a gentle warning.
In both cases, the function will return a Promise
, which is the whole point of this exercise.
Test setup
As always, I’ll use Mocha and Chai, an excellent assertion library for the tests.
If the reader is not familiar with Mocha and Chai, I wrote some posts about these libraries, please read them first here and here. I also published an article on how to set them up, so I won’t cover these concepts here again.
Let’s create then a file named index.test.js
, and place it in the same folder as index.js
.
At the top, we need to require
chai
and our function:
// index.test.js
const { expect } = require('chai')
const asyncAddFive = require('./index')
describe('#asyncAddFive', () => {
// assertions will come here
})
We can get the expect
method using destructuring, instead of writing const expect = chai.expect
.
Start a describe
block, write the name of the function to be tested in the first argument, then let’s add some assertions in the callback function.
Test the happy path
This is the easier part. Because we know that the function will return the correct result in this case (this is the situation we want to test), we can simply write the following test case inside describe
:
it('returns the number plus five if the argument is a number', async () => {
const num = 12
const expected = 17
const msg = await asyncAddFive(num)
expect(msg).to.eql(`The returned value is ${ expected }`)
})
I’m using async/await
and there’s no good reason for not doing so. Although the syntax uses promises in the background, so it’s still asynchronous, it’s much easier to read, because it creates the illusion of a synchronous code. But, again, it’s just illusion, or using the fancy expression, a syntactic sugar, async/await
is asynchronous.
As its name suggests, we’ll wait for the return value of asyncAddFive
to arrive, before we move on to the next line starting with expect
. This way, we can save the return value of the function in a variable called msg
.
The assertion (the line starting with expect
) is straightforward, we expect that the string asyncAddFive
returns will be the same as the one including the expected
value of 17.
A quick npm test
will show that the test passes.
To make sure that we have written a good assertion, change the value of expected
to 18 and run the test again. It should fail this time.
Test the error
Writing a test case for the error is slightly more difficult.
We know that asyncAddFive
will throw an error because we’ll call it in a way that it should throw an exception. We’ll feed in an argument other than a number.
We can not only use the try/catch
statement in the function, but in the body of the tests, too.
Let’s see how in the next it
block in index.test.js
:
it('should throw an error if the argument is not a number', async () => {
try {
await asyncAddFive('hello')
throw new Error('This will not run')
} catch (e) {
expect(e).to.be.instanceOf(Error)
expect(e.message).to.eql('Argument must be a number')
}
})
In the try
block, we await
asyncAddFive
.
Why didn’t I save the return value of the function in a variable this time?
Because we don’t need it. We fed in a string as the argument, and the function should throw an exception. It can be proved by throwing a custom error inside try
.
If an error occurs in the try
block, the control will immediately be passed to the catch
part. Because asyncAddFive
throws an error by its argument not being a number, the This will not run
exception will not be thrown at all.
Instead, the code will keep running in the catch
part. Here, we can expect
that the error (e
) is an instance of the Error
object, and that its message
property is what the error has been thrown with (Argument must be a number
).
The npm test
command will make this test case pass.
As always, it’s a good idea to check if we have written a good test by changing a character or two in the expectation, like changing the word number
to string
in Argument must be a number
. If the test case fails, it’s good, if it still passes, then there’s a problem with the test.
This is a nice and clean way of testing any custom errors in the logic, and I often use this technique in my work. It’s a good practice to validate arguments, types etc. and it’s also a good practice to write test cases to cover these scenarios.
An alternative approach
Chai has an extension called Chai as Promised, which was developed to help developers write tests on promises.
This extension can also be used as way to test asynchronous code.
Setup
It needs a bit of setup though, i.e. we need to add the extensions to Chai.
First, the library needs to be installed as a developer dependency with npm i -D chai-as-promised
.
Then, we can set it up in the index.test.js
file. No big deal, we only make a small change:
// index.test.js
const chai = require('chai')
const chaiAsPromised = require('chai-as-promised')
chai.use(chaiAsPromised)
const { expect } = chai
We use the use
method of Chai and make Chai as Promised available in the file. With this, the methods specifically developed to test promises will be available after expect
. There’s no difference how the assertions should be written.
Rewrite the assertions using Chai as Promised
When we test the happy path, i.e. when the function returns the correct answer, we can write something like this:
it('gives back the correct answer of the argument is a number', async () => {
const num = 12
const expected = 17
await expect(asyncAddFive(num)).to.eventually.be.eql(`The returned value is ${ expected }`)
})
We still have an async
function inside the it
block, and this time we await
the whole expect
block. The argument of expect
will be the asyncAddFive
function this time.
The word eventually
comes from the Chai as Promised library. eventually
refers to the value the promise is fulfilled with, which, in our case, is the The returned value is 17
string. We can then use eventually
to unwrap the promise when we want to test if it resolves with the expected value.
What happens if we remove eventually
from the assertion? The test will fail:
1) #asyncAddFive
gives back the correct answer of the argument is a number:
AssertionError: expected {} to deeply equal 'The returned value is 17'
at Context.it (.../index.test.js:39:43)
Why? Because by removing eventually
, the promised remains wrapped
. So let’s quickly write it back.
Assert the error
This is the part where Chai as Promised can offer a simpler syntax, because it doesn’t require try/catch
.
Instead, we can simply write just one line:
it('returns an error message if the argument is not a number', async () => {
await expect(asyncAddFive('hello')).to.be.rejectedWith('Argument must be a number')
})
Another special method is rejectedWith
, which we can use to test if the promise rejects with the given value.
With this syntax, the assertion is straightforward and easy to read . We expect
that asyncAddFive
(a promise), which is called with the string hello
, will be rejected with the Argument must be a number
message, and this is exactly what happens inside the body of the function.
These assertions could have been written in many different ways. Please refer to the documentation for the list of available methods.
Conclusion
Testing asynchronous code is a frequently required task for developers. It can be done in different ways, like using callbacks with done
, promises or async/await
.
Using async/await
in the tests is the simplest of all as it provides a synchronous way of reading code. Testing errors is as equally important as testing the happy path. The try/catch
method is one way to write tests when one decides to use async/await
. The disadvantage of this approach is that it’s more verbose and we catch the error instead of expecting it.
The Chai as Promised library is also an option to test asynchronous code and promises, and it can make the code shorter and more test-like, because we expect the error instead of catching it.
Thanks for reading and see you next time.