Skip to content

Commit e996b41

Browse files
fix(expect): support type-safe declaration of custom matchers (#7656)
Co-authored-by: Vladimir Sheremet <[email protected]>
1 parent 2854ad6 commit e996b41

File tree

4 files changed

+108
-19
lines changed

4 files changed

+108
-19
lines changed

docs/guide/extending-matchers.md

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,19 @@ expect.extend({
2525

2626
If you are using TypeScript, you can extend default `Assertion` interface in an ambient declaration file (e.g: `vitest.d.ts`) with the code below:
2727

28-
```ts
28+
::: code-group
29+
```ts [<Version>3.2.0</Version>]
30+
import 'vitest'
31+
32+
interface CustomMatchers<R = unknown> {
33+
toBeFoo: () => R
34+
}
35+
36+
declare module 'vitest' {
37+
interface Matchers<T = any> extends CustomMatchers<T> {}
38+
}
39+
```
40+
```ts [<Version>3.0.0</Version>]
2941
import 'vitest'
3042

3143
interface CustomMatchers<R = unknown> {
@@ -37,6 +49,11 @@ declare module 'vitest' {
3749
interface AsymmetricMatchersContaining extends CustomMatchers {}
3850
}
3951
```
52+
:::
53+
54+
::: tip
55+
Since Vitest 3.2, you can extend the `Matchers` interface to have type-safe assertions in `expect.extend`, `expect().*`, and `expect.*` methods at the same time. Previously, you had to define separate interfaces for each of them.
56+
:::
4057

4158
::: warning
4259
Don't forget to include the ambient declaration file in your `tsconfig.json`.
@@ -56,35 +73,45 @@ interface ExpectationResult {
5673
```
5774

5875
::: warning
59-
If you create an asynchronous matcher, don't forget to `await` the result (`await expect('foo').toBeFoo()`) in the test itself.
76+
If you create an asynchronous matcher, don't forget to `await` the result (`await expect('foo').toBeFoo()`) in the test itself::
77+
78+
```ts
79+
expect.extend({
80+
async toBeAsyncAssertion() {
81+
// ...
82+
}
83+
})
84+
85+
await expect().toBeAsyncAssertion()
86+
```
6087
:::
6188

6289
The first argument inside a matcher's function is the received value (the one inside `expect(received)`). The rest are arguments passed directly to the matcher.
6390

64-
Matcher function have access to `this` context with the following properties:
91+
Matcher function has access to `this` context with the following properties:
6592

66-
- `isNot`
93+
### `isNot`
6794

68-
Returns true, if matcher was called on `not` (`expect(received).not.toBeFoo()`).
95+
Returns true, if matcher was called on `not` (`expect(received).not.toBeFoo()`).
6996

70-
- `promise`
97+
### `promise`
7198

72-
If matcher was called on `resolved/rejected`, this value will contain the name of modifier. Otherwise, it will be an empty string.
99+
If matcher was called on `resolved/rejected`, this value will contain the name of modifier. Otherwise, it will be an empty string.
73100

74-
- `equals`
101+
### `equals`
75102

76-
This is a utility function that allows you to compare two values. It will return `true` if values are equal, `false` otherwise. This function is used internally for almost every matcher. It supports objects with asymmetric matchers by default.
103+
This is a utility function that allows you to compare two values. It will return `true` if values are equal, `false` otherwise. This function is used internally for almost every matcher. It supports objects with asymmetric matchers by default.
77104

78-
- `utils`
105+
### `utils`
79106

80-
This contains a set of utility functions that you can use to display messages.
107+
This contains a set of utility functions that you can use to display messages.
81108

82109
`this` context also contains information about the current test. You can also get it by calling `expect.getState()`. The most useful properties are:
83110

84-
- `currentTestName`
111+
### `currentTestName`
85112

86-
Full name of the current test (including describe block).
113+
Full name of the current test (including describe block).
87114

88-
- `testPath`
115+
### `testPath`
89116

90-
Path to the current test.
117+
Path to the current test.

packages/expect/src/types.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,17 +86,25 @@ export type AsyncExpectationResult = Promise<SyncExpectationResult>
8686

8787
export type ExpectationResult = SyncExpectationResult | AsyncExpectationResult
8888

89-
export interface RawMatcherFn<T extends MatcherState = MatcherState> {
90-
(this: T, received: any, ...expected: Array<any>): ExpectationResult
89+
export interface RawMatcherFn<T extends MatcherState = MatcherState, E extends Array<any> = Array<any>> {
90+
(this: T, received: any, ...expected: E): ExpectationResult
9191
}
9292

93+
// Allow unused `T` to preserve its name for extensions.
94+
// Type parameter names must be identical when extending those types.
95+
// eslint-disable-next-line
96+
export interface Matchers<T = any> {}
97+
9398
export type MatchersObject<T extends MatcherState = MatcherState> = Record<
9499
string,
95100
RawMatcherFn<T>
96-
> & ThisType<T>
101+
> & ThisType<T> & {
102+
[K in keyof Matchers<T>]?: RawMatcherFn<T, Parameters<Matchers<T>[K]>>
103+
}
97104

98105
export interface ExpectStatic
99106
extends Chai.ExpectStatic,
107+
Matchers,
100108
AsymmetricMatchersContaining {
101109
<T>(actual: T, message?: string): Assertion<T>
102110
extend: (expects: MatchersObject) => void
@@ -639,7 +647,8 @@ export type PromisifyAssertion<T> = Promisify<Assertion<T>>
639647

640648
export interface Assertion<T = any>
641649
extends VitestAssertion<Chai.Assertion, T>,
642-
JestAssertion<T> {
650+
JestAssertion<T>,
651+
Matchers<T> {
643652
/**
644653
* Ensures a value is of a specific type.
645654
*

packages/vitest/src/public/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ export type {
252252
ExpectPollOptions,
253253
ExpectStatic,
254254
JestAssertion,
255+
Matchers,
255256
} from '@vitest/expect'
256257
export {
257258
afterAll,
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { expect, expectTypeOf, test } from 'vitest'
2+
3+
interface CustomMatchers<R = unknown> {
4+
toMatchSchema: (schema: { a: string }) => R
5+
toEqualMultiple: (a: string, b: number) => R
6+
}
7+
8+
declare module 'vitest' {
9+
interface Matchers<T = any> extends CustomMatchers<T> {}
10+
}
11+
12+
test('infers matcher declaration type from a custom matcher type', () => {
13+
expect.extend({
14+
toMatchSchema(received, expected) {
15+
expectTypeOf(received).toBeAny()
16+
expectTypeOf(expected).toEqualTypeOf<{ a: string }>()
17+
18+
return { pass: true, message: () => '' }
19+
},
20+
toEqualMultiple(received, a, b) {
21+
expectTypeOf(received).toBeAny()
22+
expectTypeOf(a).toBeString()
23+
expectTypeOf(b).toBeNumber()
24+
25+
return { pass: true, message: () => '' }
26+
},
27+
})
28+
29+
expect({ a: 1, b: '2' }).toMatchSchema({ a: '1' })
30+
expect('a').toEqualMultiple('a', 1)
31+
})
32+
33+
test('automatically extends asymmetric matchers', () => {
34+
expect({}).toEqual({
35+
nestedSchema: expect.toMatchSchema({
36+
a: '1',
37+
// @ts-expect-error Unknown property.
38+
b: 2,
39+
}),
40+
})
41+
})
42+
43+
test('treats matcher declarations as optional', () => {
44+
expect.extend(
45+
/**
46+
* @note Although annotated, you don't have to declare matchers.
47+
* You can call `expect.extend()` multiple times or get the matcher
48+
* declarations from a third-party library.
49+
*/
50+
{},
51+
)
52+
})

0 commit comments

Comments
 (0)