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')
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);
}
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);
});
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);
});
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
});
Then, since we're using Jest, add Jest to our dependencies with:
yarn add --dev jest
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
}
});
and the function has to return a JavaScript object with at least these two fields:
-
pass
, which is true if the value we pass intoexpect
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 }
}
});
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);
});
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);
});
Run npx jest
again, and the error message we get would look like this:
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`;
}
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:
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);
Now the error message in the command line will look like this.
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`;
}
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);
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
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',
}
and now, ts-jest will run each time we run Jest, so let's try that out. Run npx jest
and you'll get:
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 {};
- 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 JestMatchers
interface type. - Under that interface, we add our method
toBeWithinOneMinuteOf
and have it take in aDate
, and return a generic typeR
.
With this defined, now run npx jest
and TypeScript now knows about the toBeWithinOneMinuteOf
method! 🎊
🗺 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)
Does not work for me, where do you put you jest.d.ts
Interesting read :)