DEV Community

Corey Cleary
Corey Cleary

Posted on

Why isn't this unit test catching an error from this async/await function?

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
}
Enter fullscreen mode Exit fullscreen mode

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()
  })
})
Enter fullscreen mode Exit fullscreen mode

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')
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Then change the test to be:

describe('#fetchItem', () => {
  it('should catch an error', async () => {
    await expect(fetchItem(3)).to.be.rejected
  })
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

you could use return

return expect(fetchItem(3)).to.be.rejected
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
frederikheld profile image
Frederik Held

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

Collapse
 
alexm77 profile image
Alex Mateescu

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.

Collapse
 
szpadel profile image
Szpadel

It just should be
expect(await fetchItem(3)).to.eventually.throw()

Collapse
 
ccleary00 profile image
Corey Cleary

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.

Collapse
 
szpadel profile image
Szpadel

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

Collapse
 
lexlohr profile image
Alex Lohr

In Jest it is even simpler, you just need not forget expect.assertions(numberOfAsyncAssertions);: jestjs.io/docs/en/tutorial-async

Collapse
 
nguynvnhhi1 profile image
Nguyễn Vĩnh Hải

@@. 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.