Engineering

Practical Guide to Custom Jest Matchers

What is a matcher?

In Jest, functions like .toEqual() or .toHaveProperty() are called matchers. While Jest comes with an extensive amount of default matchers, you can also create your own to encapsulate repetitive assertions in your tests.

Symmetric and Asymmetric matchers

There are two types of matchers in Jest: symmetric and asymmetric.

A symmetric matcher is the one that asserts data in its entirety. In other words, it's a strict comparison matcher. Here's an example:

1expect(myObject).toEqual({ id: 123 })

In this assertion, the myObject must equal to the { id: 123 } object. If it doesn't have the required "id" property or has additional properties that are not present in the expected object, the assertion will fail. In that regard, this matcher is symmetric because it reflects the expected value in its entirety.

An asymmetric matcher is a kind of a matcher that asserts data partially. Here's the same object assertion as above but using an asymmetric matcher now:

1expect(myObject).toEqual(
2 expect.objectContaining({
3 id: 123,
4 })
5)

The symmetric .toEqual matcher remains but you may notice that instead of accepting an object as its argument, it now accepts the call to expect.objectContaining() function. The latter is the asymmetric matcher, as it describes a subset of properties that must exist on myObject, ignoring any additional properties.

Creating a custom matcher

Jest provides the expect.extend() API to implement both custom symmetric and asymmetric matchers. This API accepts an object where keys represent matcher names, and values stand for custom matcher implementations.

Extending the default expect function can be done as a part of the testing setup. Make sure you have your Jest configuration file created and pointing to the custom setup file:

1export default {
2 // Let Jest know that there's an additional setup
3 // before the tests are run (i.e. matcher extensions).
4 setupFilesAfterEnv: ['./jest.setup.ts'],
5}

Let's start by implementing a custom symmetric matcher.

Custom symmetric matcher

In our application, we often assert that a number lies within the range of numbers. To reduce the repetition and make tests reflect the intention, let's implement a custom .toBeWithinRange() matcher.

1// jest.setup.ts
2expect.extend({
3 toBeWithinRange(actual, min, max) {
4 if (typeof actual !== 'number') {
5 throw new Error('Actual value must be a number')
6 }
7
8 const pass = actual >= min && actual <= max
9 },
10})

Here, we've extended the Jest's expect global function with a new function called toBeWithinRange. Jest will always provide our matchers with the actual data as the first argument, and we can utilize the remaining arguments for the matcher to accept additional input (for example, the allowed range of numbers).

Since anything can be passed to the expect() in the test run, don't forget to check for the actual type. In this matcher, we ensure that the provided actual value is a number.

We are checking whether the actual number is within the min and max range, and writing the result to the pass variable. Now we need to let Jest know how to respect that variable and mark assertions as passed or failed based on its value.

To do that, custom matchers must return an object of the following shape:

1{
2 pass: boolean
3 message(): string
4}

Let's do just that:

1// jest.setup.ts
2expect.extend({
3 toBeWithinRange(actual, min, max) {
4 if (typeof actual !== 'number') {
5 throw new Error('Actual value must be a number')
6 }
7
8 const pass = actual >= min && actual <= max
9
10 return {
11 pass,
12 message: pass
13 ? () => `expected ${actual} not to be within range (${min}..${max})`
14 : () => `expected ${actual} to be within range (${min}..${max})`,
15 }
16 },
17})

Now, whenever our matcher returns { pass: false }, the test assertion will fail, and Jest will communicate the failure to us as it usually does.

Notice how we're returning a conditional message value, even if the matcher has passed. That is done due to inverse matches, with which you are also very likely familiar:

1expect(5).not.toBeWithinRange([3, 5])

For our matcher, 5 is indeed within the given range of [3, 5], so it will return { pass: true }. But it's the .not. chain that makes this assertion inverted, flipping it upside down. Jest knows that inverse matches must return { pass: false }, and whenever that's not the case, it will print the message that we've defined for that case. And that is why we still return a message when the matcher passes, and why that message says that "the number must not be within range".

The final touch is to let TypeScript know that we've just extended a globally exposed function of a third-party library. To do that, create a jest.d.ts file and extend jest.Matchers there:

1import { MatcherFunction } from 'expect'
2
3declare global {
4 namespace jest {
5 interface Matchers<R, T> {
6 // Note that we are defining a public call signature
7 // for our matcher here (how it will be used):
8 // expect(5).toBeInRange(3, 7)
9 toBeWithinRange(min: number, max: number): T
10 }
11
12 interface ExpectExtendMap {
13 // Here, we're describing the call signature of our
14 // matcher for the "expect.extend()" call.
15 toBeWithinRange: MatcherFunction<[min: number, max: number]>
16 }
17 }
18}

There's a handy MatcherFunction type exported from the "expect" package (this is the actual "expect" Jest exposes for us globally) that simplifies annotation of custom matcher declarations. The type itself is a generic that accepts a list of custom matcher's arguments. In the example above, we've described our matcher as a function that has two arguments: min and max—both of type number.

Also, let's make sure that this definition is included in tsconfig.json:

1{
2 "include": ["jest.d.ts"]
3}

We can now use our custom matcher in tests:

1it('asserts the number is within range', () => {
2 expect(5).toBeWithinRange(3, 5) // ✅
3 expect(3).toBeWithinRange(10, 20) // ❌
4})
5
6it('asserts the number is not within range', () => {
7 expect(10).not.toBeWithinRange([3, 5]) // ✅
8 expect(5).not.toBeWithinRange([1, 10]) // ❌
9})

Custom asymmetric matcher

Similar to symmetric matchers, asymmetric ones are defined via expect.extend() in your test setup file.

Let's create a custom asymmetric matcher that asserts that a given Set has a subset of values.

1// jest.setup.ts
2expect.extend({
3 // ...any other custom matchers.
4
5 setContaining(actual, expected) {
6 if (!(actual instanceof Set)) {
7 throw new Error('Actual value must be a Set')
8 }
9
10 const pass = expected.every((item) => actual.has(item))
11
12 return {
13 pass,
14 message: pass
15 ? () => `expected Set not to contain ${expected.join(', ')}`
16 : () => `expected Set to contain ${expected.join(', ')}`,
17 }
18 },
19})

Since the setContaining matcher is asymmetric, it should be exposed as expect.setContaining() and not expect(x).setContaining(). Let's make sure we extend the jest.Expect type with our asymmetric matcher instead of extending the jest.Matchers type like we did with toBeWithinRange.

1// jest.d.ts
2import { MatcherFunction } from 'expect'
3
4declare global {
5 namespace jest {
6 // ...any other extensions, like "Matchers".
7
8 interface Expect {
9 // Once again, here we describe how our matcher
10 // will be used in our tests:
11 // expect.setContaining(['john'])
12 setContaining<T extends unknown>(expected: Set<T>): Set<T>
13 }
14
15 interface ExpectExtendMap {
16 // Let's keep our extension signature type-safe.
17 setContaining: MatcherFunction<[expected: unknown[]]>
18
19 // ...any other matcher definitions.
20 toBeWithinRange: MatcherFunction<[min: number, max: number]>
21 }
22 }
23}

Once this is done, we can use our custom asymmetric matcher in tests:

1it('asserts a subset of the given Set values', () => {
2 expect({ friends: new Set(['john', 'kate']) }).toEqual({
3 friends: expect.setContaining(['kate']), // ✅
4 })
5
6 // Annotating the actual data will give us type-safety
7 // down to each individual asymmetric matcher.
8 interface User {
9 friends: Set<string>
10 }
11
12 expect(user).toEqual<User>({
13 friends: expect.setContaining([5]),
14 // TypeError: "number" is not assignable to type "string".
15 })
16})

You may have noticed that the expect.extend() part is identical for both types of matchers. In fact, even our asymmetric matcher will be exposed as expect(v).setContaining(subset) during test runtime. However, to preserve semantic names, I highly recommend describing symmetric matchers by extending jest.Matchers, and the asymmetric ones by extending jest.Expect separately. It's a type limitation only but it will produce more consistent test matcher semantics.

Conclusion

And that's how you extend Jest with both symmetric and asymmetric matchers while preserving the type-safety of your tests. Custom matchers are certainly not a beginner-friendly topic but they are indispensable when it comes to designing a custom testing vocabulary in somewhat larger projects.

You can browse through the source code from this article in this repository:

Fetching kettanaito/jest-custom-matchers repository...

As a cherry on top, here are a few resources that can expand your knowledge about Jest matchers:

Thank you for reading! Make sure to follow me on Twitter if you wish to stay in tune when I publish more pieces like this. You can also share this one with your coworkers and friends, I'd highly appreciate that.

Artem Zakharchenko

Artem Zakharchenko

@kettanaito

Hi! My name is Artem and I am a Full-stack JavaScript engineer, rock-n-roll musician and medical doctor.

If you like my material, please consider following me on Twitter to get notified when new posts are published, ask me a question and stay in touch.

Articles You May Enjoy

EngineeringJune 6, 2021

Writing a custom webpack loader

Excel your webpack knowledge by learning how to write a custom webpack loader that turns an MP3 file import into an interactive audio player.