Originally published at coreycleary.me. This is a cross-post from my content blog. I publish new content every week or two, and you can sign up to my newsletter if you'd like to receive my articles directly to your inbox! I also regularly send cheatsheets, links to other great tutorials (by other people), and other freebies.
When you're writing unit tests for asynchronous functions in JavaScript, one test case you'll usually want to have is making sure the async function throws an error in case of an error scenario.
Let's imagine writing a test for an item function that calls a database and returns an item:
const fetchItem = async function (itemName) {
if (typeof itemName !== 'string') {
throw new Error('argument should be a string')
} else {
return await db.select(itemName)
}
}
module.exports = {
fetchItem
}
Note: normally I don't like doing type checks on arguments, but this is easy for demonstration purposes.
A reasonable unit test for this might look like:
const { fetchItem } = require('../path/to/fn')
describe('#fetchItem', () => {
it('should catch an error', async () => {
await expect(fetchItem(3)).to.eventually.throw()
})
})
In this case, we call the fetchItem()
function with an argument that is not a string (which our database query will expect). It's an async function so we await
it and expect it to eventually throw
, since the function will throw a new Error
if passed a non-string argument.
It seems like it should pass, right?
Then why does the test fail with an uncaught error? Why does the error just show up in the console without the test passing?
Let's take a look at why it's not working, and how to fix it...
Why doesn't it work like you expect it to?
The beauty of async/await
is that it makes asynchronous code read as if it were synchronous code. So synchronous that it can be easy to forget you're still dealing with async code.
It's important to remember that in JavaScript whenever you have a function with the async
keyword, it always returns a Promise. And when you have a function that returns a Promise it's either resolved or rejected.
When we throw that error like we did in the fetchItem()
function,
if (typeof itemName !== 'string') {
throw new Error('argument should be a string')
}
it's really rejecting the Promise. It will reject with an error, but it's a rejected Promise, nonetheless.
The fix
The fix for this is very simple. Import chai-as-promised into your tests like so:
const chai = require('chai')
const chaiAsPromised = require('chai-as-promised');
const expect = chai.expect
chai.use(chaiAsPromised)
Then change the test to be:
describe('#fetchItem', () => {
it('should catch an error', async () => {
await expect(fetchItem(3)).to.be.rejected
})
})
All that changed was instead of to.eventually.throw()
, it becomes to.be.rejected
. If you want to test to make sure it's rejected with the right error message, you can change it to to.be.rejectedWith('argument should be a string')
.
A note on return vs await
Chai will wait for Promises, so instead of using await
await expect(fetchItem(3)).to.be.rejected
you could use return
return expect(fetchItem(3)).to.be.rejected
I prefer to use await
as it reminds me that I'm working with an async
function, but this is worth pointing out in case you find other examples using return
.
Wrapping up
With native Promises, where you explicitly reject the Promise when you hit an error scenario, it's a bit easier to remember that you're testing for a rejected Promise, not a caught error.
I've written plenty of working tests for async/await
functions that throw errors, but it's still an easy thing to forget. I encountered it recently when I was writing the code for the post on scenarios for unit testing Node services, which involved a lot of asynchronous code. And by the way, if you're looking for a list of common tests you should have for Node services, definitely check out that post.
I think testing should be as easy as possible in order to remove the barriers to actually writing them. It's one thing to get stuck on code - you don't have any choice but to fix it. But its another thing to get stuck on tests - with tests you technically can skip them.
I'm trying to make testing and other things in JavaScript easier by sending out tutorials, cheatsheets, and links to other developers' great content. Here's that link again to sign up to my newsletter again if you found this tutorial helpful!
Top comments (9)
"It's one thing to get stuck on code - you don't have any choice but to fix it. But its another thing to get stuck on tests - with tests you technically can skip them."
This is very insightful. Thanks for that! This actually happens to me quite often and IMHO testing with Chai isn't as straightforward as it should be :-P There's too many situations like this. I got stuck a lot of times and when I didn't find a solution, lost interest in my project :-(
I don't know if it is my personal dislike of Javascript or async code being a bitch to test in general, but this is really convoluted. The stuff even seasoned programmers will get wrong more often than not.
It just should be
expect(await fetchItem(3)).to.eventually.throw()
Unfortunately this doesn't work and just leads to the same issue. We're expecting a rejected promise with an error, not just an error.
Await unwraps Promise and maps promise reject to throw, so you can catch it in try..catch so you should get there thrown error.
After second thought exception in this case is thrown before passing it to expect.
Better solution would be to wrap it in try catch block and test error that is caught.
You really want to make sure what error is caught, because you might get also other issue, like TypeError, and test will pass
In Jest it is even simpler, you just need not forget
expect.assertions(numberOfAsyncAssertions);
: jestjs.io/docs/en/tutorial-async@@. it's all things i'm looking for . thanks you alot :(.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.