Skip to content

Commit 63b9166

Browse files
committed
03/02: add exercise texts
1 parent 088dabb commit 63b9166

File tree

19 files changed

+347
-4
lines changed

19 files changed

+347
-4
lines changed
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
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!
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"type": "module",
3+
"name": "exercises_03.assertions_02.problem.asymmetric-matchers",
4+
"scripts": {
5+
"test": "vitest"
6+
},
7+
"devDependencies": {
8+
"@types/node": "^22.14.1",
9+
"vite": "^6.2.6",
10+
"vitest": "^3.1.1"
11+
},
12+
"dependencies": {
13+
"zod": "^3.24.2"
14+
}
15+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { fetchTransaction, type Transaction } from './fetch-transaction'
2+
import { userSchema } from './schemas'
3+
4+
test('fetches a transaction between two users', async () => {
5+
// 🐨 Declare a variable called `transaction` and assign it the result
6+
// of calling `fetchTransaction` with the argument `'transaction-1'`.
7+
// Notice that the `fetchTransaction` function returns a promise.
8+
// 💰 const foo = await bar()
9+
//
10+
// 🐨 Next, write an assertion that the returned `transaction` equals
11+
// a transaction object. Such an object has the following properties:
12+
// - `id` equal to `'transaction-1'`;
13+
// - `issuer` that matches the `userSchema` schema;
14+
// - `recipient` that matches the `userSchema` schema.
15+
// 💰 expect(transaction).toEqual<Transaction>({})
16+
// 💰 expect.toMatchSchema(schema)
17+
})
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { User } from './schemas'
2+
3+
export interface Transaction {
4+
id: string
5+
issuer: User
6+
recipient: User
7+
}
8+
9+
/**
10+
* Returns the transaction with the given ID.
11+
*/
12+
export async function fetchTransaction(id: string): Promise<Transaction> {
13+
return {
14+
id,
15+
issuer: {
16+
id: 'user-123',
17+
name: 'John Maverick',
18+
},
19+
recipient: {
20+
id: 'user-456',
21+
name: 'Kate Wilson',
22+
},
23+
}
24+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { fetchUser } from './fetch-user'
2+
import { userSchema } from './schemas'
3+
4+
test('returns the user by id', async () => {
5+
const user = await fetchUser('abc-123')
6+
// 🦉 The call signature of `expect.toMatchSchema()` must be correct.
7+
expect(user).toEqual(expect.toMatchSchema(userSchema))
8+
})
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { User } from './schemas'
2+
3+
/**
4+
* Returns the user with the given ID.
5+
*/
6+
export async function fetchUser(id: string): Promise<User> {
7+
return {
8+
id,
9+
name: 'John Maverick',
10+
}
11+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import z from 'zod'
2+
3+
export const userSchema = z.object({
4+
id: z.string(),
5+
name: z.string(),
6+
})
7+
8+
export type User = z.infer<typeof userSchema>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ESNext",
4+
"module": "ESNext",
5+
"useDefineForClassFields": true,
6+
"skipLibCheck": true,
7+
8+
/* Bundler mode */
9+
"moduleResolution": "bundler",
10+
"allowImportingTsExtensions": true,
11+
"isolatedModules": true,
12+
"moduleDetection": "force",
13+
"noEmit": true,
14+
"verbatimModuleSyntax": true,
15+
16+
/* Linting */
17+
"strict": true,
18+
"noUnusedLocals": false,
19+
"noUnusedParameters": false,
20+
"noFallthroughCasesInSwitch": true,
21+
"noUncheckedSideEffectImports": true
22+
}
23+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"files": [],
3+
"references": [
4+
{ "path": "./tsconfig.src.json" },
5+
{ "path": "./tsconfig.node.json" },
6+
{ "path": "./tsconfig.test.json" }
7+
]
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"extends": "./tsconfig.base.json",
3+
"include": ["vitest.config.ts"],
4+
"compilerOptions": {
5+
"lib": ["ES2023"],
6+
"types": ["node"]
7+
}
8+
}

0 commit comments

Comments
 (0)