|
| 1 | +# Asymmetric matchers |
| 2 | + |
| 3 | +_Assymetric matcher_ is the one where the `actual` value is literal while the `expected` value is an _expression_. |
| 4 | + |
| 5 | +```ts nonumber |
| 6 | +// 👇 Literal string |
| 7 | +expect('hello world').toBe(expect.stringContaining('hello')) |
| 8 | +// 👆 Expression matching many strings |
| 9 | +``` |
| 10 | + |
| 11 | +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_. |
| 12 | + |
| 13 | +<callout-success>Asymmetric matchers are fantastic for expectations that go beyond |
| 14 | +literal values.</callout-success> |
| 15 | + |
| 16 | +Here are a few more examples of asymmetric matchers for you to consider: |
| 17 | + |
| 18 | +```ts nonumber |
| 19 | +// Must be an object containing the "id" property that is a string. |
| 20 | +expect(user).toEqual(expect.objectContaining({ id: expect.any(String) })) |
| 21 | + |
| 22 | +// Must be an array with exactly two elements that are numbers. |
| 23 | +expect(caretPosition).toEqual([expect.any(Number), expect.any(Number)]) |
| 24 | +``` |
| 25 | + |
| 26 | +<callout-warning> |
| 27 | + It is important to point out that in addition to asymmetric matchers all of my |
| 28 | + examples also include _structural comparison_: `.toBe()`, `.toEqual()`, etc. |
| 29 | + But instead of comparing the actual and expected values, it compares the |
| 30 | + actual value to the _matcher result_, which is what an asymmetric matcher |
| 31 | + returns. |
| 32 | + |
| 33 | +This is what sets asymmetric matchers apart from _symmetric_ matchers that don't involve literal values, like `expect('hello').toMatch(/hello/)`. |
| 34 | +</callout-warning> |
| 35 | + |
| 36 | +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: |
| 37 | + |
| 38 | +```ts nonumber highlight=3-7 |
| 39 | +expect(user).toEqual({ |
| 40 | + id: 'abc-123', |
| 41 | + posts: expect.arrayContaining([ |
| 42 | + expect.objectMatching({ |
| 43 | + id: expect.any(String), |
| 44 | + }), |
| 45 | + ]), |
| 46 | +}) |
| 47 | +``` |
| 48 | + |
| 49 | +> Here, the `user` object is expected to literally match the object with the `id` and `posts` properties. While the expectation toward the `id` property is literal, the `posts` proprety is described as an abstract `Array<{ id: string }>` object. |
| 50 | +
|
| 51 | +## `.toMatchSchema()` |
| 52 | + |
| 53 | +With that in mind, what kind of matcher is our custom `.toMatchSchema()`? 🤔 |
| 54 | + |
| 55 | +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: |
| 56 | + |
| 57 | +```ts nonumber |
| 58 | +expect('hello').toMatch(/hello/) // symmetric |
| 59 | +expect(user).toMatchSchema(userSchema) // also symmetric |
| 60 | + |
| 61 | +expect('hello').toEqual(expect.stringMatching(/hello/)) // asymmetric |
| 62 | +expect(user).toEqual(expect.toMatchSchema(userSchema)) // ??? |
| 63 | +``` |
| 64 | + |
| 65 | +Wait, can we even use it as an asymmetric matcher? Let's find out: |
| 66 | + |
| 67 | +```ts filename=src/fetch-user.test.ts remove=6 add=7 |
| 68 | +import { fetchUser } from './fetch-user' |
| 69 | +import { userSchema } from './schemas' |
| 70 | + |
| 71 | +test('returns the user by id', async () => { |
| 72 | + const user = await fetchUser('abc-123') |
| 73 | + expect(user).toMatchSchema(userSchema) |
| 74 | + expect(user).toEqual(expect.toMatchSchema(userSchema)) |
| 75 | +}) |
| 76 | +``` |
| 77 | + |
| 78 | +``` |
| 79 | +npm test |
| 80 | +
|
| 81 | + ✓ src/fetch-user.test.ts (1 test) 2ms |
| 82 | + ✓ returns the user by id 1ms |
| 83 | +``` |
| 84 | + |
| 85 | +Somehow, that assertion also **passes**! 😮 |
| 86 | + |
| 87 | +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. |
| 88 | + |
| 89 | +<callout-success>The `.toMatchSchema()` matcher is _both symmetric and asymmetric_ depending on how it's being used.</callout-success> |
| 90 | + |
| 91 | +**There is a slight problem though...** Types. |
| 92 | + |
| 93 | +```ts |
| 94 | +test('returns the user by id', async () => { |
| 95 | + const user = await fetchUser('abc-123') |
| 96 | + expect(user).toEqual(expect.toMatchSchema(userSchema)) |
| 97 | + // ❌ ^^^^^^^^^^^^^ |
| 98 | + // Property 'toMatchSchema' does not exist on type 'ExpectStatic'.ts(2339) |
| 99 | +}) |
| 100 | +``` |
| 101 | + |
| 102 | +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? |
| 103 | + |
| 104 | +## Your task |
| 105 | + |
| 106 | +👨💼 You! Your task right now is to modify the module augmentation in <InlineFile file="vitest.setup.ts" /> 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 <InlineFile file="src/fetch-user.test.ts" /> for that). |
| 107 | + |
| 108 | +👨💼 Once the type story is solved, I want to you give the asymmetric matchers a try. In the <InlineFile file="src/fetch-transaction.test.ts" />, you will find an unfinished test case. Complete it using the asymmetric `expect.toMatchSchema()` matcher and have it passing! |
0 commit comments