How to test the Error cause with Jest and TypeScript

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.

Did you find this article valuable?

Support Javier Aguilar by becoming a sponsor. Any amount is appreciated!