How to test the Error cause with Jest and TypeScript
Extending Jest in TypeScript to write custom matchers for testing additional Error properties.
When developing applications, it's essential to handle errors effectively, and throwing errors is a common way to signal that something has gone wrong. However, defining multiple error classes for each error scenario can quickly become overwhelming and make our codebase cluttered.
To avoid this, many developers opt for using error codes instead of error classes. These error codes are simply strings or numbers that represent the type of error that has occurred.
When using error codes, it's common to pass additional information about the error in the error message. But testing the error message makes tests a little ugly and has some drawbacks:
You have to use RegExp to test the error message
Many errors may have similar error messages, leading to misleading tests with false positives/negatives
When error message changes, you have to change your tests
This is where the options
parameter of the JS Error
class comes in. It allows us to pass additional information about the error that can be useful in understanding why the error was thrown.
You can pass an options
object as a second argument of the Error
constructor. It can have a cause
property that can be of any type or another Error
.
This pattern of throwing errors with a cause
is widely used in JavaScript, and testing the value of the cause
can make your tests more robust.
Unfortunately, Jest's built-in matchers do not provide a way to test extra properties of an Error, and that's where extending the Jest matchers in TypeScript comes in handy.
All you have to do is to define your custom matchers in your setupTests.js
file, by calling expect.extend()
// setupTests.js
const catchError = (callback) => {
try {
callback()
} catch (error) {
return error
}
}
/**
* @type {ExpectExtendMap & MatchersExtend<any>}
*/
const customMatchers = {
toThrowWithCause(received, cause) {
const err = catchError(received)
const passes = (err instanceof Error) && (err.cause === cause)
const actualCause = String(err ? `got: ${err.cause}` : 'no error was thrown')
console.log('Error Cause', actualCause)
if (err && err.cause === undefined) {
console.error('Error was thrown, but cause was undefined. Error:', err)
}
return passes ? ({
pass: true, // not.toThrowWithCause
message: () => `Expected callback not to throw an Error with cause '${cause}'`,
}) : ({
pass: false, // .toThrowWithCause
message: () => `Expected callback to throw an Error with cause '${cause}', but ${actualCause}`,
})
},
}
expect.extend(customMatchers)
You also need to extend the Jest types:
// setupTests.d.ts
export {}
declare global {
namespace jest {
interface AsymmetricMatchers {
toThrowWithCause(cause: string | Error): void
}
interface Matchers<R> {
toThrowWithCause(cause: string | Error): R
}
type MatcherNames = keyof Matchers<any>
type MatchersExtend<R> = {
[Key in MatcherNames]: (
received: any,
...params: Parameters<Matchers<R | void>[Key]>
) => R | void
}
}
}
And then you can use it in your tests like this:
const myParser = (version: string) => {
throw new Error(`Invalid version ${version}`, { cause: 'INVALID_VERSION' })
}
it('throws an error if the version is wrong', () => {
expect(() => myParser('0.,4')).toThrowWithCause('INVALID_VERSION')
})
By using custom matchers to catch the error, we will not only make it more reusable, but we will also make our linters happy since it's considered a bad practice to catch errors manually inside tests.