DEV Community

Andy Haskell
Andy Haskell

Posted on

Making custom Jest assertion matchers in JavaScript and TypeScript

Jest is a powerful tool for testing your JavaScript code, giving an expect syntax that reads like a sentence, making it to reason about, such as:

let someCuteAnimals = ['sloths', 'lemurs', 'gophers'];
expect(someCuteAnimals).toContain('sloths')
Enter fullscreen mode Exit fullscreen mode

Each one of those expect methods starting with to is called a matcher, and there are a lot of them, like toBe for checking that two values are equal, toBeGreaterThan for checking that a number is greater than another number, and toMatch to check that a string matches a regular expression.

Something really cool about expect is that if none of the built-in matchers fit what you want to test, you can add your own matcher with expect.extend, so in this tutorial, we're gonna learn:

  • ✏️ how to teach Jest a new matcher
  • 💬 how to customize the matcher's error message
  • 🦋 how to have TypeScript recognize the matcher

This tutorial assumes you have some familiarity with how to write a Jest test, as well as the basics of TypeScript, namely the concept of interface types. If you're not too familiar with TypeScript declaration files just yet though, that's all right, we'll be looking at that near the end of the tutorial.

🕰 Devising a matcher

Let's say we made a GPS app for sloths to tell them the best path to climb in order to get to some tasty cecropia leaves. 🦥🍃

Three-toed sloths have a speed of about 0.15mph, so 792 feet per hour or about 13 feet per minute. So a function to give a sloth an ETA for their climb might look something like:

function climbingETA(startTime, distanceInFeet) {
  let durationInMin = distanceInFeet / 13;

  // convert to milliseconds, the smallest unit of duration that's
  // represented in a JavaScript Date.
  let durationInMS = Math.floor(durationInMin * 60 * 1000);

  return new Date(startTime.getTime() + durationInMS);
}
Enter fullscreen mode Exit fullscreen mode

To test this, the things we would have our tests assert are things like that if a sloth starts climbing at a certain time, we get back an ETA that's a minute later for every 13 feet the sloth climbs, so that would look something like this pseudocode:

test('it takes ten minutes to climb 130 feet', () => {
  let eta = climbingETA(threeOClock, 130);
  expect(eta).toBe(threeTen);
});
Enter fullscreen mode Exit fullscreen mode

But while that works for round numbers like climbing 130 feet in 10 minutes, what if a sloth was climbing 131 feet? That's still basically ten minutes, but using the toBe matcher, we'd be expecting the ETA toBe some timeframe right down to millisecond precision. Writing that JavaScript Date would be painful to write and makes our tests cumbersome to read. So what if instead, we had the matcher toBeWithinOneMinuteOf? Then our test could look like this:

test('it takes about ten minutes to climb 131 feet', () => {
  let eta = climbingETA(threeOClock, 130);
  expect(eta).toBeWithinOneMinuteOf(threeTen);
});
Enter fullscreen mode Exit fullscreen mode

Now the code reads "expect the ETA for climbing 131 feet to be within a minute of 3:10 PM", not the over the top precision like "expect the ETA to be 3:10:04 and 615 milliseconds". Much less a headache to work with that test! So let's see how we can add our own custom matcher!

✏️ Teaching Jest a new matcher

First, let's start off by making our test file. If you're following along in your own code, in a new folder, add the file gps.test.js with this code:

// in a real app this wouldn't be in the test coverage, but we'll
// keep it there to keep this tutorial's code simple
function climbingETA(startTime, distanceInFeet) {
  let durationInMin = distanceInFeet / 13;
  let durationInMS = Math.floor(durationInMin * 60 * 1000);
  return new Date(startTime.getTime() + durationInMS);
}

test('it takes about ten minutes to climb 131 feet', () => {
  // [TODO] Write the test coverage
});
Enter fullscreen mode Exit fullscreen mode

Then, since we're using Jest, add Jest to our dependencies with:

yarn add --dev jest
Enter fullscreen mode Exit fullscreen mode

Great, now we're all set up! For adding a new matcher, we use the expect.extend method. We pass in an object with each matcher function we want to add to expect. So adding our matcher function would look like this:

expect.extend({
  toBeWithinOneMinuteOf(got, expected) {
    // [TODO] write the matcher
  }
});
Enter fullscreen mode Exit fullscreen mode

and the function has to return a JavaScript object with at least these two fields:

  • pass, which is true if the value we pass into expect causes the matcher to succeed
  • and message, which is a function deriving the error message to if the matcher fails

So let's add this toBeWithinOneMinuteOf matcher function to gps.test.js:

expect.extend({
  toBeWithinOneMinuteOf(got, expected) {
    const oneMinute = 60 * 1000; // a minute in milliseconds

    let timeDiff = Math.abs(expected.getTime() - got.getTime());
    let timeDiffInSeconds = timeDiff / 1000;

    let pass = timeDiff < oneMinute;
    let message = () =>
      `${got} should be within a minute of ${expected}, ` +
        `actual difference: ${timeDiffInSeconds.toFixed(1)}s`;

    return { pass, message }
  }
});
Enter fullscreen mode Exit fullscreen mode

We calculate the difference between the expected time and the actual time. If it's less than a minute, then in the object we return the pass field is true, causing the matcher to succeed. Otherwise, pass is false causing the matcher to fail.

In the object we return, if the test fails, Jest shows our error message specified with message. We had it tell us the actual difference, in seconds, between the time we expected and the time we got.

expect() now has a brand new method called toBeWithinOneMinuteOf it didn't have before, so let's try it out! Update our test to this code:

test('it takes about ten minutes to climb 131 feet', () => {
  let threeOClock = new Date('2020-12-29T03:00:00');
  let threeTen    = new Date('2020-12-29T03:10:00');

  let eta = climbingETA(threeOClock, 131);
  expect(eta).toBeWithinOneMinuteOf(threeTen);
});
Enter fullscreen mode Exit fullscreen mode

Then run npx jest and you should see not only does our new matcher work, but the test passed with flying colors! 🐦🌈

💬 Customizing the error message

The test passes, but let's see what happens if it were to fail. Let's change the expected time to 3:12 PM and see what error message we get:

test('it takes about ten minutes to climb 131 feet', () => {
  let threeOClock = new Date('2020-12-29T03:00:00');
  let threeTen    = new Date('2020-12-29T03:10:00');
  let threeTwelve = new Date('2020-12-29T03:12:00');

  let eta = climbingETA(threeOClock, 131);
  expect(eta).toBeWithinOneMinuteOf(threeTwelve);
});
Enter fullscreen mode Exit fullscreen mode

Run npx jest again, and the error message we get would look like this:

Jest running in the command line with the error message saying "Tue Dec 29 2020 03:10:04 GMT-0500 (Eastern Standard Time) should be within a minute of Tue Dec 29 2020 03:12:00 GMT-0500 (Eastern Standard Time), actual difference: 115.4s"

We get an accurate error message, but the timestamps for the actual and expected times are cumbersome to read. For times where we just want to know if they're a minute apart, we shouldn't need to think about the date and time zone, so let's simplify the error message function. If you're following along in your own editor, try changing the error message function to this code:

let message = () => {
  let exp = expected.toLocaleTimeString();
  let gt = got.toLocaleTimeString();
  return `${gt} should be within a minute of ${exp}, ` +
    `actual difference: ${timeDiffInSeconds.toFixed(1)}s`;
}
Enter fullscreen mode Exit fullscreen mode

toLocaleTimeString represents a JavaScript Date with just the hour, minute, and second of the timestamp, without time zone or date. So if we run the test again, the error message should be:

The failing test, this time with the more legible error message "3:10:04 AM should be within a minute of 3:12:00 AM, actual difference: 115.4s"

Much better! There's just one other problem. You can modify any Jest matcher with not, so what error message would we get if we changed our expect line to this?

expect(eta).not.toBeWithinOneMinuteOf(threeTen);
Enter fullscreen mode Exit fullscreen mode

Now the error message in the command line will look like this.

Jest running with the failing test again, but now the error message says "3:10:04 AM should be within a minute of 3:10:00 AM"

We're saying that the time we got should be within a minute of the time we expected, but the test actually expects that the time we got is not within a minute, making a confusing error message.

The problem is, we're displaying the same error message whether pass is true or not. And a matcher with the not modifier fails when pass is true.

So that means when pass is true, the error message should say that the time we got should not be within a minute of the time we expected. Let's tweak the message one more time:

let message = () => {
  let exp = expected.toLocaleTimeString();
  let gt = got.toLocaleTimeString();

  if (pass) {
    // error message when we have the not modifier, so pass is
    // supposed to be false
    return `${gt} should not be within a minute of ${exp}, ` +
      `difference: ${timeDiffInSeconds.toFixed(1)}s`;
  }
  // error message when we don't have the not modifier, so pass
  // is supposed to be true
  return `${gt} should be within a minute of ${exp}, ` +
    `actual difference: ${timeDiffInSeconds.toFixed(1)}s`;
}
Enter fullscreen mode Exit fullscreen mode

Now if we run the test one more time with npx jest, we will get an error message that makes sense both with and without the not modifier! 🎉

If you're following along in your own code, remove the not modifier so the expect reads

expect(eta).toBeWithinOneMinuteOf(threeTen);
Enter fullscreen mode Exit fullscreen mode

and then let's see how we would use our matcher in TypeScript!

🦋 Running the test in TypeScript

Now let's see how we would get our new matcher to work in TypeScript. First, rename gps.test.js to gps.test.ts.

Now since we're doing TypeScript, we want to have a step of our testing where we check that everything's the right type before we go ahead and run the test. And there's a convenient preset for Jest for that called ts-jest. Let's get ts-jest and TypeScript by running:

yarn add --dev typescript ts-jest
Enter fullscreen mode Exit fullscreen mode

We install the dependencies, and if you look in the node_modules/@types folder, you'll see there's a jest package, because @types/jest ia a dependency of ts-jest. What that means for us is that the TypeScript compiler now knows about all TypeScript types for Jest, like the type of the expect function and all its matchers like toBe. This because by default, the TypeScript compiler looks for type definitions in node_modules/@types. We didn't have to install @types/jest ourselves!

To have Jest use ts-jest, we need to add just a bit of configuration. Add a new file named jest.config.js with this code:

module.exports = {
  preset: 'ts-jest',
}
Enter fullscreen mode Exit fullscreen mode

and now, ts-jest will run each time we run Jest, so let's try that out. Run npx jest and you'll get:

Our tests running in the command line with the error message "Property 'toBeWithinOneMinuteOf' does not exist on type 'JestMatchersShape<Matchers<void, Date>, Matchers<Promise<void>, Date>>'"

Another error message! This one is a type error from the TypeScript compiler, so let's take a closer look.

The type callers Matchers is the type of the object we get from the function expect(). When we do expect(eta), the return value is a Matchers and it includes all the different built-in matcher methods on it like toBe and toContain.

When we ran expect.extend, though, in JavaScript, we gave that Matchers type a new toBeWithinOneMinuteOf method. However, the problem is, while JavaScript knows about that method, TypeScript doesn't.

If you're a deep-diver like me and want to see exactly where TypeScript gets the information on what the Matchers type looks like, it's under the TypeScript Matchers interface. That interface has all the built-in matchers methods you can see in Jest's documentation, but not the one we made.

Luckily, you can tell the TypeScript compiler "the Jest Matchers interface includes all the matchers in @types/jest, but then it's also got these other matcher methods I wrote". We do this using a technique called declaration merging.

Basically, you make a declaration file like the index.d.ts file in @types/jest, with a Matchers interface that has just the methods you wrote. Then, TypeScript looks at the Matchers interface in your declaration file, plus the one in @types/jest, to get a combined definition of the Matchers that includes your methods.

To make the declaration, add this code to a file titled jest.d.ts.

declare global {
  namespace jest {
    interface Matchers<R> {
      toBeWithinOneMinuteOf(expected: Date): R
    }
  }
}

export {};
Enter fullscreen mode Exit fullscreen mode
  • The line namespace jest indicates that we're declaring code in Jest's namespace.
  • Under the Jest namespace, we are declaring code in interface Matchers<R>, which means we're defining properties and methods on the Jest Matchers interface type.
  • Under that interface, we add our method toBeWithinOneMinuteOf and have it take in a Date, and return a generic type R.

With this defined, now run npx jest and TypeScript now knows about the toBeWithinOneMinuteOf method! 🎊

Jest suite running in the command line, with the test passing

🗺 Where do we go next with this?

We've defined our own custom matcher, designed its error message, and by adding it to a .d.ts file, now TypeScript can work with the new method! Since we can do that, that means we can add custom matchers for pretty much any common pattern we want to test in our codebase.

In addition to custom matchers you wrote, the Jest community also has made a bunch of extra convenient matchers in a JS module jest-extended. You can check it out here, and its README file has some great documentation on each of its matchers!

When you're building a JavaScript app, as it grows, be on the lookout for places where it's often cumbersome to write test coverage with existing Jest Matchers. That might just be the opportunity to make a matcher that makes tests a whole lot easier for you and anyone else on your dev team to be able to write and reason about!

Top comments (2)

Collapse
 
julestruong profile image
Jules Truong

Does not work for me, where do you put you jest.d.ts

Collapse
 
shadid12 profile image
Shadid Haque

Interesting read :)