Asymmetric matchers
Assymetric matcher is the one where the
actual
value is literal while the expected
value is an expression.// 👇 Literal string
expect('hello world').toBe(expect.stringContaining('hello'))
// 👆 Expression matching many strings
Above, the
expect.stringContaining()
matcher is asymmetric because it doesn't describe a literal value but instead creates what is, effectively, a regular expression that can match multiple strings (/hello/
). It describes a logical equality, not structural.Here are a few more examples of asymmetric matchers for you to consider:
// Must be an object containing the "id" property that is a string.
expect(user).toEqual(expect.objectContaining({ id: expect.any(String) }))
// Must be an array with exactly two elements that are numbers.
expect(caretPosition).toEqual([expect.any(Number), expect.any(Number)])
It is important to point out that in addition to asymmetric matchers all of my
examples also include structural comparison:
.toBe()
, .toEqual()
, etc.
But instead of comparing the actual and expected values, it compares the
actual value to the matcher result, which is what an asymmetric matcher
returns.This is what sets asymmetric matchers apart from symmetric matchers that don't involve literal values, like
expect('hello').toMatch(/hello/)
.In addition to this, asymmetric matchers are great for testing nested data structures as they allow you to describe expectations within the expected literal value:
expect(user).toEqual({
id: 'abc-123',
posts: expect.arrayContaining([
expect.objectMatching({
id: expect.any(String),
}),
]),
})
Here, theuser
object is expected to literally match the object with theid
andposts
properties. While the expectation toward theid
property is literal, theposts
proprety is described as an abstractArray<{ id: string }>
object.
.toMatchSchema()
With that in mind, what kind of matcher is our custom
.toMatchSchema()
? 🤔It does accept a Zod
schema
, which is not a literal value we want to compare anything to. But on the other hand, it embodies the whole comparison, no matter if literal or not, instead of representing a matcher result:expect('hello').toMatch(/hello/) // symmetric
expect(user).toMatchSchema(userSchema) // also symmetric
expect('hello').toEqual(expect.stringMatching(/hello/)) // asymmetric
expect(user).toEqual(expect.toMatchSchema(userSchema)) // ???
Wait, can we even use it as an asymmetric matcher? Let's find out:
import { fetchUser } from './fetch-user'
import { userSchema } from './schemas'
test('returns the user by id', async () => {
const user = await fetchUser('abc-123')
expect(user).toMatchSchema(userSchema)
expect(user).toEqual(expect.toMatchSchema(userSchema))
})
npm test
✓ src/fetch-user.test.ts (1 test) 2ms
✓ returns the user by id 1ms
Somehow, that assertion also passes! 😮
That is happening because Vitest automatically treats custom matchers as both symmetric and asymmetric, allowing you to implement them just once and use them as you see fit.
.toMatchSchema()
matcher is both symmetric and asymmetric depending on how it's being used.There is a slight problem though... Types.
test('returns the user by id', async () => {
const user = await fetchUser('abc-123')
expect(user).toEqual(expect.toMatchSchema(userSchema))
// ❌ ^^^^^^^^^^^^^
// Property 'toMatchSchema' does not exist on type 'ExpectStatic'.ts(2339)
})
At the moment of writing this exercise, Vitest does not extend the asymmetric matchers interface to let TypeScript know what type
expect.toMatchSchema()
is. But you know who will?Your task
👨💼 You! Your task right now is to modify the module augmentation in so that asymmetric matchers are recognized on the type level. Since the tests are passing as-is, you will use your IDE to verify that the custom
.toMatchSchema()
matcher has correct type definitions (use the modified for that).