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 Matchers<T> extends CustomMatchers<T> {}
}
Here, I'm augmenting the Matchers type from vitest with my custom CustomMatchers type. By doing so, I'm extending the type definitions for the expect() function as well as the argument type of expect.extend() for later.
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 Matchers<T> extends CustomMatchers<T> {}
}

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, a boolean indicating whether the received 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)).
Notice that I'm using a built-in 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)
})
Notice that unlike custom test context, you don't have to import a custom version of the 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.

Please set the playground first

Loading "Custom matchers"
Loading "Custom matchers"
Login to get access to the exclusive discord channel.
  • general
    Modals / Dialogs
    Lucas Wargha πŸš€ 🌌:
    It seems like modals and dialogs are becoming a hot topic on my team lately. I haven’t found a solid...
    • βœ…1
    3 Β· 3 months ago
  • general
    Welcome to EpicWeb.dev! Say Hello πŸ‘‹
    Kent C. Dodds β—† πŸš€πŸ†πŸŒŒβš‘:
    This is the first post of many hopefully!
    • 18
    86 Β· 2 years ago
  • general
    epic stack website initial load at home page is unstyled (sometimes)
    osmancakir πŸš€ 🌌:
    Sometimes (especially when it is loaded first time on a new browser etc.) I see this unstyled versio...
    • βœ…1
    10 Β· 6 months ago
  • general
    Resource / Api endpoints on epic stack / RR7
    Lucas Wargha πŸš€ 🌌:
    Hi everyone! Quick question for those using the Epic Stack: How are you handling resource routes ...
    • βœ…1
    2 Β· 5 months ago
  • general
    Epic stack using tanstack form
    Lucas Wargha πŸš€ 🌌:
    https://github.com/epicweb-dev/epic-stack/compare/epicweb-dev:main...wargha:feature/tanstack-form-ex...
    • βœ…1
    3 Β· 5 months ago
  • general
    Init command outdated on the EpicWeb website
    Virgile πŸ† 🌌:
    Hi everyone. I've initialized a new epic-stack project yesterday. Following instructions from http...
    • βœ…1
    3 Β· 5 months ago
  • general
    Mark as complete, resets the first time you click it.
    Daniel V.C πŸš€ 🌌:
    Not sure if anyone else has had this issue, as i've not seen anyone else talk about it, but I find ...
    • βœ…1
    8 Β· 6 months ago
  • πŸ’Ύdata
    general
    πŸ“forms
    πŸ”­foundations
    double underscore?
    trendaaang 🌌:
    What with the `__note-editor.tsx`? I don't see that in the Remix docs and I don't remember Kent talk...
    • βœ…1
    2 Β· a year ago
  • general
    Keeping Epic Stack Projects Free on Fly – Any Tips?
    Lucas Wargha πŸš€ 🌌:
    I’ve been experimenting with the Epic Stack and deploying some dummy projects on Fly. I noticed that...
    • βœ…1
    0 Β· 6 months ago
  • πŸ’Ύdata
    general
    πŸ“forms
    πŸ”­foundations
    Creating Notes
    Scott 🌌 πŸ†:
    Does anybody know in what workshop we create notes? I would like to see the routing structure. So fa...
    • βœ…1
    2 Β· 8 months ago
  • πŸ”­foundations
    πŸ’Ύdata
    general
    πŸ“forms
    πŸ”auth
    Thank you for the inspiration
    Binalfew πŸš€ 🌌 ⚑:
    <@105755735731781632> I wanted to thank you for the incredible knowledge I gained from your Epic Web...
    • ❀️1
    1 Β· 8 months ago
  • general
    npm install everytime I setup a new playground
    Duki 🌌:
    Is it normal that I have to run `npm install` in my playground directory, everytime I setup the play...
    • βœ…1
    2 Β· a year ago
  • general
    Migration to Vite: Server-only module referenced by client
    Fabian 🌌:
    Hi, I'm working on migrating to Vite following the remix docs (https://remix.run/docs/en/main/guides...
    • βœ…1
    1 Β· a year ago
  • general
    Remix Vite Plugin
    Binalfew πŸš€ 🌌 ⚑:
    <@105755735731781632> Now that remix officially supports vite (though not stable) what does it mean...
    • βœ…1
    3 Β· 2 years ago
  • general
    πŸ”­foundations
    Solutions video on localhost:5639 ?
    quang πŸš€ 🌌:
    Hi, so I'm having a hard time navigating (hopefully will be better with time) The nav on epicweb.de...
    • βœ…1
    9 Β· 2 years ago
  • general
    Epicshop is now social and mobile friendly!
    Kent C. Dodds β—† πŸš€πŸ†πŸŒŒβš‘:
    I'm excited to announce that now the Epic Web workshops are mobile friendly! https://foundations.ep...
    • πŸŽ‰2
    0 Β· a year ago