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:
expect(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 setup3 // 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.ts2expect.extend({3 toBeWithinRange(actual, min, max) {4 if (typeof actual !== 'number') {5 throw new Error('Actual value must be a number')6 }7 const pass = actual >= min && actual <= max8 },9})
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: boolean3 message(): string4}
Let's do just that:
1// jest.setup.ts2expect.extend({3 toBeWithinRange(actual, min, max) {4 if (typeof actual !== 'number') {5 throw new Error('Actual value must be a number')6 }7 const pass = actual >= min && actual <= max8 return {9 pass,10 message: pass11 ? () => `expected ${actual} not to be within range (${min}..${max})`12 : () => `expected ${actual} to be within range (${min}..${max})`,13 }14 },15})
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:
expect(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
and jest.ExpectExtendMap
interfaces there:
1type OwnMatcher<Params extends unknown[]> = (2 this: jest.MatcherContext,3 actual: unknown,4 ...params: Params5) => jest.CustomMatcherResult6declare global {7 namespace jest {8 interface Matchers<R, T> {9 // Note that we are defining a public call signature10 // for our matcher here (how it will be used):11 // expect(5).toBeInRange(3, 7)12 toBeWithinRange(min: number, max: number): T13 }14 interface ExpectExtendMap {15 // Here, we're describing the call signature of our16 // matcher for the "expect.extend()" call.17 toBeWithinRange: OwnMatcher<[min: number, max: number]>18 }19 }20}
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})5it('asserts the number is not within range', () => {6 expect(10).not.toBeWithinRange([3, 5]) // ✅7 expect(5).not.toBeWithinRange([1, 10]) // ❌8})
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.ts2expect.extend({3 // ...any other custom matchers.4 setContaining(actual, expected) {5 if (!(actual instanceof Set)) {6 throw new Error('Actual value must be a Set')7 }8 const pass = expected.every((item) => actual.has(item))9 return {10 pass,11 message: pass12 ? () => `expected Set not to contain ${expected.join(', ')}`13 : () => `expected Set to contain ${expected.join(', ')}`,14 }15 },16})
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.ts2import { MatcherFunction } from 'expect'3declare global {4 namespace jest {5 // ...any other extensions, like "Matchers".6 interface Expect {7 // Once again, here we describe how our matcher8 // will be used in our tests:9 // expect.setContaining(['john'])10 setContaining<T extends unknown>(expected: Set<T>): Set<T>11 }12 interface ExpectExtendMap {13 // Let's keep our extension signature type-safe.14 setContaining: MatcherFunction<[expected: unknown[]]>15 // ...any other matcher definitions.16 toBeWithinRange: MatcherFunction<[min: number, max: number]>17 }18 }19}
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 // Annotating the actual data will give us type-safety6 // down to each individual asymmetric matcher.7 interface User {8 friends: Set<string>9 }10 expect(user).toEqual<User>({11 friends: expect.setContaining([5]),12 // TypeError: "number" is not assignable to type "string".13 })14})
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 asexpect(v).setContaining(subset)
during test runtime. However, to preserve semantic names, I highly recommend describing symmetric matchers by extendingjest.Matchers
, and the asymmetric ones by extendingjest.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:
kettanaito/jest-custom-matchers
As a cherry on top, here are a few resources that can expand your knowledge about Jest matchers: