Custom matchers
Declaring a matcher
I will start by implementing the custom
.toMatchSchema()
matcher. This will include both extending the type definitions for vitest
and defining the matcher logic itself.I already have a
vitest.setup.ts
setup file in my project. I will declare my custom matcher in that setup file. And since my project uses TypeScript, I will approach this in a type-first manner:import type { Schema } from 'zod'
interface CustomMatchers<MatcherResult = any> {
toMatchSchema: (schema: Schema) => MatcherResult
}
Here, I am declaring a new interface called
CustomMatchers
that will describe all the custom matchers I want in my tests. It accepts the MatcherResult
type argument, which is a requirement in order to make matchers work correctly with Vitest.The type definition for the matcher itself describes it as a simple function that accepts a
schema
and returns the MatchResult
. This is how you can imagine this call signature upon usage:expect(unknown).toMatchSchema(schema) // MatcherResult
At the moment, this type definition exists in a vacuum. It doesn't do anything. I need to extend some of the existing types in Vitest in order for my custom matchers to be applied on the type level:
import type { Schema } from 'zod'
interface CustomMatchers<MatcherResult = any> {
toMatchSchema: (schema: Schema) => MatcherResult
}
declare module 'vitest' {
interface Assertion<T = any> extends CustomMatchers<T> {}
interface MatchersDeclaration extends CustomMatchers {}
}
There are two interfaces from
vitest
that I extend using module augmentgation:Assertion<T>
, which controls the matchers returned by callingexpect()
;MatchersDeclaration
, which annotates the matcher declarations passed toexpect.extend()
.
This is enough for the
.toMatchSchema()
custom matcher to be recognized by TypeScript:expect({}).toMatchSchema(mySchema) // ✅
But this is only half of the story. Running this
expect()
statement will throw an error since I haven't provided the actual implementation for the matcher yet. Let's fix that!To provide a matcher implementation, call
expect.extend()
and provide it with an object that contains the definitions for your custom matchers:import { expect } from 'vitest'
import type { Schema } from 'zod'
interface CustomMatchers<MatcherResult = any> {
toMatchSchema: (schema: Schema) => MatcherResult
}
declare module 'vitest' {
interface Assertion<T = any> extends CustomMatchers<T> {}
interface MatchersDeclaration extends CustomMatchers {}
}
expect.extend({
toMatchSchema(received, expected) {},
})
This is a barebones definition for any custom matcher. You can notice that the
.toMatchSchema()
function doesn't have the same call signature as I defined in the CustomMatchers
interface. That is because Vitest will call this matcher with the additional received
argument. That is the value passed to the assertion expect(HERE)
.In the matcher declaration, I will parse the given
received
object using the expected
schema:import { expect } from 'vitest'
import type { Schema } from 'zod'
// ...
expect.extend({
toMatchSchema(received, expected) {
const result = expected.safeParse(received)
if (!result.success) {
return {}
}
return {}
},
})
By checking the
result.success
of the schema parsing, I can control the result of this matcher, failing it if the parsing was unsuccessful and marking it as passing if it wasn't.I control the matcher result by returning the matcher object. Let me show you.
import { expect } from 'vitest'
import type { Schema } from 'zod'
// ...
expect.extend({
toMatchSchema(received, expected) {
const result = expected.safeParse(received)
if (!result.success) {
return {
pass: false,
message: () => 'Does not match the schema',
actual: this.utils.printReceived(received),
expected: result.error.format(),
}
}
return {
pass: true,
message: () => 'Matches the schema',
actual: this.utils.printReceived(received),
}
},
})
The matcher result tells Vitest how to treat this custom matcher and consists of the following properties:
pass
, aboolean
indicating whether thereceived
value matched the `expected;message
, a function that produces the success or error message.actual
, the actual value provided to the assertion (expect(THIS)
);expected
, the expected value provided to the assertion (expect(...).toMatchSchema(THIS)
).
this.utils.printReceived()
function so that Vitest would print the given objects and schemas nicely in the test output. Explore the this.utils
object to discover more helpers to write great custom matchers!This concludes the implementation of my custom matcher! 🎉
All that is left to do for it to work is to make sure that the
vitest.setup.ts
file is indeed used as the setup in Vitest:import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'node',
setupFiles: ['./vitest.setup.ts'],
},
})
Refactoring tests
Now I can finally refactor my
fetch-user.test.ts
tests to benefit from the custom matcher.import { fetchUser } from './fetch-user'
import { userSchema } from './schemas'
test('returns the user by id', async () => {
const user = await fetchUser('abc-123')
const result = userSchema.safeParse(user)
expect(result.error).toBeUndefined()
expect(result.data).toEqual({
id: 'abc-123',
name: 'John Maverick',
})
expect(user).toMatchSchema(userSchema)
})
expect()
function. Matchers are extended in-place and available the same way as your regular expect()
function, which in our case is globally (due to test.globals: true
in vitest.config.ts
).This transforms a test case riddled with implementation details into a descriptive and concise reflection of what I expect from the
fetchUser()
function. All thanks to the power of custom matchers.