The Context
When testing JavaScript applications, it’s often necessary to verify that expected errors are triggered under specific conditions. However, catching errors in asynchronous logic can be tricky because of how JavaScript handles asynchronous operations.
As part of my journey to deepen my understanding of JavaScript testing, I’ve been working through the Test-Driven Development for JavaScript course. In the section on Testing errors from callback functions, I came across this example:
function slowOperationWithError(a, b, callback) {
setTimeout(() => {
if (b === 0) {
throw new Error("Cannot divide by 0")
} else {
callback(a / b)
}
}, 2000)
}
The accompanying test code looked like this:
test("test - incorrect technique", (done) => {
try {
slowOperationWithError(10, 0, null)
} catch (err) {
expect(err).toBe("Cannot divide by 0")
}
done()
})
Surprisingly, the test passed. However, upon closer inspection, it became clear that this was a false positive. The done
callback was invoked before the slowOperationWithError
function had a chance to finish executing, which meant the target function wasn’t tested at all. Furthermore, the code in the catch
block would never run, as the error was thrown asynchronously.
This led me to investigate why this happens and how to fix it. Here’s what I learned.
The Cause
The root cause lies in how JavaScript handles asynchronous errors. The try/catch
block can only catch errors thrown synchronously. To illustrate, consider the following example:
try {
throw new Error("This will be caught!")
} catch (err) {
console.error("Caught:", err.message)
}
console.log("Finish running.")
# Console output
Caught: This will be caught!
Finish running.
In this case, the error is thrown synchronously, so the catch
block captures it and logs the error message.
However, if the error is thrown inside an asynchronous operation, such as a setTimeout
, the try/catch
block will no longer apply because the asynchronous code runs in a different phase of the event loop. Here’s an example:
try {
setTimeout(() => {
throw new Error("This won't be caught!")
}, 3000)
} catch (err) {
console.error("Caught:", err.message)
}
console.log("Before timeout.")
# Console output
Before timeout.
Uncaught Error: This won't be caught!
The try/catch
block finishes execution before the setTimeout
callback is invoked, so the error is unhandled. This behavior explains why the original test code doesn’t work:
test("test - incorrect technique", (done) => {
try {
slowOperationWithError(10, 0, null) // Error is thrown after 2 seconds
} catch (err) {
expect(err).toBe("Cannot divide by 0") // Never runs
}
done() // Invoked immediately, before the error occurs
})
The test completes in less than 0.2 seconds because done
is called prematurely, and Jest warns that the asynchronous operation is still running in the background.
Time: 0.157 s, estimated 6 s
# other output omitted
Jest did not exit one second after the test run has completed.
The Fix
To fix the test, we need to ensure that:
- The asynchronous error is passed back to the test framework.
- The test waits for the asynchronous operation to complete before calling done.
Here’s the revised test code:
test("test - correct technique", (done) => {
slowOperationWithError(10, 0, null, (error) => {
expect(error).toBe("Cannot divide by 0")
done()
})
})
In this version, the slowOperationWithError
function accepts an error callback. When the error condition is met, the callback is invoked with the error message. This allows our matcher (expect) to verify that the expected error occurs. Finally, we call done
to signal that the test has finished.
To make this test work, we need to modify the production code slightly:
function slowOperationWithError(a, b, okCallback, errCallback /* Add this */) {
setTimeout(() => {
if (b === 0) {
errCallback("Cannot divide by 0") // Use the callback here
} else {
okCallback(a / b)
}
}, 2000)
}
By passing errors through a callback instead of throwing them, we ensure that the test has control over asynchronous error handling. The test now explicitly waits for the error callback to be invoked, which prevents premature termination and ensures the error behavior is properly tested.
Conclusion
Testing asynchronous code in JavaScript requires a clear understanding of how errors are handled across different phases of the event loop. The key takeaway is that try/catch
only works for synchronous code. For asynchronous logic, always pass errors through callbacks, promises, or async/await
patterns.
By adopting these practices, we can write reliable tests that accurately validate your application’s behavior, even under complex asynchronous conditions.