7

This is a design question that came up to me while unit testing. Let's dive into the example:

Imagine this:

async function foo() {
    try {
        return apiCall()
    }
    catch (e) {
        throw new CustomError(e);
    } 
}



async function bar() {
    return foo()
}



async function main() {
    try {
        await bar()
    }catch(e) {
        console.error(e)
    }
}

main()

What do we see here? That the only function that hasn't got a try-catch block is bar. But if foo fails, it should get catched by the main catch.

While unittesting this like

describe('testing bar', () => {
    it('foo should throw', () => {
        foo.mockImplementantion(() => { throw new CustomError('error')});
        bar()
        .then((result) => console.log(result))
        .catch((err) => { exepect(err).toBeInstanceOf(CustomError)}) // this is what we are testing
    })
})

The output we see is that an Unhandled promise rejection is logged in the console.

So, my question is... even if I know that the main() will catch the error, should I use try-catch block inside all async functions?

skyboyer
  • 22,209
  • 7
  • 57
  • 64
Julian Mendez
  • 3,162
  • 2
  • 13
  • 36
  • Because a node.js process can/will terminate from an unhandled promise rejection, it is advisable that you always use try/catch with await, and always add a .catch handler to the end of Promise chains. – Jared Smith Aug 04 '20 at 16:59
  • what you are telling me is that I should wrap the function `bar()` inside a try catch block, right? – Julian Mendez Aug 04 '20 at 17:02
  • The async function `bar` returns a promise right? The async function `foo` can throw an exception in it's catch block which causes an **Unhandled promise rejection** in `bar`, so........ – gforce301 Aug 04 '20 at 18:00
  • Your test case doesn't `return` the promise or use `async`/`await` nor does it have a `done` callback, so it's likely that `expect` is throwing an exception about being called after the test has ended. What's the error message of the unhandled rejection? – Bergi Aug 04 '20 at 19:24
  • @Bergi (node:44872) UnhandledPromiseRejectionWarning: CustomError – Julian Mendez Aug 04 '20 at 20:55

3 Answers3

3

try..catch may be necessary if a function is able to recover from an error, do a side effect like logging, or re-throw a more meaningful error.

If CustomError is more preferable than an error that apiCall can throw then try..catch necessary, otherwise it doesn't. Also the problem with foo is that it handles only synchronous errors. In order to handle rejected promises, it should be return await apiCall(), this is a known pitfall of async.

Uncaught rejections are unwanted, they currently result in UnhandledPromiseRejectionWarning and are expected to crash Node in future versions. It's preferable to handle an error in a meaningful way at top level, so main needs to catch the error. This can be delegated to process uncaughtRejection event handler but it may be beneficial for it to stay extra level of error handling that should be never reached.

The output we see is that an Unhandled promise rejection is logged in the console.

This shouldn't happen. A rejection needs to be handled by the test. One possible point of failure is explained above, foo can return original error from apiCall instead of CustomError in case it wasn't correctly mocked, this will fail the expectation and result in unhandled rejection in catch(). Another point of failure is that the test has unchained promise because it wasn't returned, the test always passes.

Asynchronous test that uses promises should always return a promise. This can be improved by using async..await. foo is async, it's expected to always return a promise:

it('foo should throw', async () => {
    foo.mockImplementantion(() => { return Promise.reject(new CustomError('error')) });
    await expect(bar()).rejects.toThrow(CustomError);
})

Now even if foo mock fails (foo mock won't affect bar if they are defined in the same module as shown) and bar rejects with something that is not CustomError, this will be asserted.

Estus Flask
  • 206,104
  • 70
  • 425
  • 565
  • I have three comments about this 1. try..catch may be necessary if a function is able to recover from an error, do a side effect like logging, or re-throw a more meaningful error. This is right, it doesn´t matter in this case 2. I'm pretty sure you can't do return await.... you should just use return 3. I've change some code in the test and now is passing – Julian Mendez Aug 04 '20 at 21:13
  • 1
    @JulianMendez `return await` is certainly possible, and [even necessary inside `try` blocks](https://stackoverflow.com/a/43985067/1048572) – Bergi Aug 04 '20 at 21:51
  • @JulianMendez So after all it was just the test case being broken? Can you show us how you fixed it? – Bergi Aug 04 '20 at 21:52
2

No. You don't need to use try/catch in every async/await. You only need to do it at the top level. In this case your main function which you are already doing.

Weather you should is a matter of opinion. The go language designers feel strongly enough about this that is has become the standard in go to always handle errors at each function call. But this is not the norm in javascript or most other languages.

Unhandled promise rejection

Your unhandled promise rejection is thrown by your it() function because you are not telling it to wait for the promise to complete.

I assume you are using something like mocha for the unit test (other frameworks may work differently). In mocha there are two ways to handle asynchronous tests:

  1. Call the done callback - the it() function will always be called with a done callback. It is up to you weather you want to use it or like in your posted code to not use it:

     describe('testing bar', () => {
         it('foo should throw', (done) => {
             foo.mockImplementantion(() => { throw new CustomError('error')});
             bar()
             .then((result) => {
                 console.log(result);
                 done(); // ------------- THIS IS YOUR ACTUAL BUG
              })
             .catch((err) => {
                 exepect(err).toBeInstanceOf(CustomError);
                 done(); // ------------- THIS IS YOUR ACTUAL BUG
             })
         })
     })
    
  2. Return a Promise. If you return a promise to the it() function mocha will be aware that your code is asynchronous and wait for completion:

     describe('testing bar', () => {
         it('foo should throw', (done) => {
             foo.mockImplementantion(() => { throw new CustomError('error')});
    
             return bar() // <----------- THIS WOULD ALSO FIX IT
             .then((result) => {
                 console.log(result);
              })
             .catch((err) => {
                 exepect(err).toBeInstanceOf(CustomError);
             })
         })
     })
    

In short, there is nothing wrong with your code. But you have a bug in your unit test.

slebetman
  • 109,858
  • 19
  • 140
  • 171
  • 1
    Note that this answer is merely rephrasing what @Bergi is trying to tell you in the comments. Your reaction to his comment tells me you didn't understand what he was trying to tell you (no, you don't need to modify your code - modify your unit test) so I elaborated. – slebetman Aug 05 '20 at 01:12
  • Oh right, I actually understood what @Bergi said to me in half. There are a lot of solutions around there for my problem and I think the same as you do: I don't need try/catch block in every async/await I use. I will post another solution to the problem – Julian Mendez Aug 05 '20 at 02:06
  • This is Jest but it works the same way as Mocha in this regard. 1 could be skipped because it shows what's wrong with `done` in promises. Most times a dev doesn't have enough discipline to guarantee correct control flow for it. In this case `done` after an assertion will result in test timeout because it's never called when an assertion fails. It should be `.then(() => done(), done.fail)` as the last chain to cover all the bases... except that it shouldn't because this is already done when a promise is returned like shown in 2. TL;DR: don't use `done` with promises. – Estus Flask Aug 05 '20 at 06:47
0

As @Bergi told me I will post some solutions right here

I wrap the function in a try catch block

1.

async function bar() {
    try{
       return foo()
    } catch (e) {
       throw e
    }
}
  1. Rewrite the test
describe('testing bar', () => {
    it('foo should throw', (done) => {
        foo.mockImplementantion(() => { throw new CustomError('error')});
        bar()
        .then((result) => { throw result }) // this is because we are expecting an error, so if the promise resolves it's actually a bad sign.
        .catch((err) => { 
          exepect(err).toBeInstanceOf(CustomError)}) // this is what we are testing
          done();
    })
})
  1. Use return in the test case
describe('testing bar', () => {
    it('foo should throw', () => {
        foo.mockImplementantion(() => { throw new CustomError('error')});
        return bar()
        .then((result) => { throw result })
        .catch((err) => { exepect(err).toBeInstanceOf(CustomError)}) // this is what we are testing
    })
})
Julian Mendez
  • 3,162
  • 2
  • 13
  • 36
  • 1
    1. `try..catch` in `try{ return foo() } catch (e) { throw e }` is a no-op and regarding the question, yes, this is something you certainly want to avoid, in contrast to original `foo`. And it still doesn't catch asynchronous errors due to how `async` works. 2. will result in test timeout when done is unreachable, this is the reason why `done` shouldn't be used with promises, they provide superior control flow in tests. 3. `.then((result) => { throw result })` will cause false positive in case `foo` erroneously returns CustomError instead of throwing, just saw a question that actually did this. – Estus Flask Aug 05 '20 at 06:16
  • Re the false positive when `bar()` returns a `CustomError`: you should use [`then(result => { throw new Error("Expected rejection, got "+result); }, err => { exepect(err).toBeInstanceOf(CustomError)})`](https://stackoverflow.com/a/24663315/1048572) instead of the `.catch()` that would handle the thrown result. – Bergi Aug 05 '20 at 16:38