fix(expect): support type-safe declaration of custom matchers (#7656)

Co-authored-by: Vladimir Sheremet <sleuths.slews0s@icloud.com>
This commit is contained in:
Artem Zakharchenko 2025-05-19 15:12:53 +02:00 committed by GitHub
parent 2854ad663f
commit e996b4103b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 108 additions and 19 deletions

View File

@ -25,7 +25,19 @@ expect.extend({
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:
```ts
::: code-group
```ts [<Version>3.2.0</Version>]
import 'vitest'
interface CustomMatchers<R = unknown> {
toBeFoo: () => R
}
declare module 'vitest' {
interface Matchers<T = any> extends CustomMatchers<T> {}
}
```
```ts [<Version>3.0.0</Version>]
import 'vitest'
interface CustomMatchers<R = unknown> {
@ -37,6 +49,11 @@ declare module 'vitest' {
interface AsymmetricMatchersContaining extends CustomMatchers {}
}
```
:::
::: tip
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.
:::
::: warning
Don't forget to include the ambient declaration file in your `tsconfig.json`.
@ -56,35 +73,45 @@ interface ExpectationResult {
```
::: warning
If you create an asynchronous matcher, don't forget to `await` the result (`await expect('foo').toBeFoo()`) in the test itself.
If you create an asynchronous matcher, don't forget to `await` the result (`await expect('foo').toBeFoo()`) in the test itself::
```ts
expect.extend({
async toBeAsyncAssertion() {
// ...
}
})
await expect().toBeAsyncAssertion()
```
:::
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.
Matcher function have access to `this` context with the following properties:
Matcher function has access to `this` context with the following properties:
- `isNot`
### `isNot`
Returns true, if matcher was called on `not` (`expect(received).not.toBeFoo()`).
Returns true, if matcher was called on `not` (`expect(received).not.toBeFoo()`).
- `promise`
### `promise`
If matcher was called on `resolved/rejected`, this value will contain the name of modifier. Otherwise, it will be an empty string.
If matcher was called on `resolved/rejected`, this value will contain the name of modifier. Otherwise, it will be an empty string.
- `equals`
### `equals`
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.
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.
- `utils`
### `utils`
This contains a set of utility functions that you can use to display messages.
This contains a set of utility functions that you can use to display messages.
`this` context also contains information about the current test. You can also get it by calling `expect.getState()`. The most useful properties are:
- `currentTestName`
### `currentTestName`
Full name of the current test (including describe block).
Full name of the current test (including describe block).
- `testPath`
### `testPath`
Path to the current test.
Path to the current test.

View File

@ -86,17 +86,25 @@ export type AsyncExpectationResult = Promise<SyncExpectationResult>
export type ExpectationResult = SyncExpectationResult | AsyncExpectationResult
export interface RawMatcherFn<T extends MatcherState = MatcherState> {
(this: T, received: any, ...expected: Array<any>): ExpectationResult
export interface RawMatcherFn<T extends MatcherState = MatcherState, E extends Array<any> = Array<any>> {
(this: T, received: any, ...expected: E): ExpectationResult
}
// Allow unused `T` to preserve its name for extensions.
// Type parameter names must be identical when extending those types.
// eslint-disable-next-line
export interface Matchers<T = any> {}
export type MatchersObject<T extends MatcherState = MatcherState> = Record<
string,
RawMatcherFn<T>
> & ThisType<T>
> & ThisType<T> & {
[K in keyof Matchers<T>]?: RawMatcherFn<T, Parameters<Matchers<T>[K]>>
}
export interface ExpectStatic
extends Chai.ExpectStatic,
Matchers,
AsymmetricMatchersContaining {
<T>(actual: T, message?: string): Assertion<T>
extend: (expects: MatchersObject) => void
@ -639,7 +647,8 @@ export type PromisifyAssertion<T> = Promisify<Assertion<T>>
export interface Assertion<T = any>
extends VitestAssertion<Chai.Assertion, T>,
JestAssertion<T> {
JestAssertion<T>,
Matchers<T> {
/**
* Ensures a value is of a specific type.
*

View File

@ -252,6 +252,7 @@ export type {
ExpectPollOptions,
ExpectStatic,
JestAssertion,
Matchers,
} from '@vitest/expect'
export {
afterAll,

View File

@ -0,0 +1,52 @@
import { expect, expectTypeOf, test } from 'vitest'
interface CustomMatchers<R = unknown> {
toMatchSchema: (schema: { a: string }) => R
toEqualMultiple: (a: string, b: number) => R
}
declare module 'vitest' {
interface Matchers<T = any> extends CustomMatchers<T> {}
}
test('infers matcher declaration type from a custom matcher type', () => {
expect.extend({
toMatchSchema(received, expected) {
expectTypeOf(received).toBeAny()
expectTypeOf(expected).toEqualTypeOf<{ a: string }>()
return { pass: true, message: () => '' }
},
toEqualMultiple(received, a, b) {
expectTypeOf(received).toBeAny()
expectTypeOf(a).toBeString()
expectTypeOf(b).toBeNumber()
return { pass: true, message: () => '' }
},
})
expect({ a: 1, b: '2' }).toMatchSchema({ a: '1' })
expect('a').toEqualMultiple('a', 1)
})
test('automatically extends asymmetric matchers', () => {
expect({}).toEqual({
nestedSchema: expect.toMatchSchema({
a: '1',
// @ts-expect-error Unknown property.
b: 2,
}),
})
})
test('treats matcher declarations as optional', () => {
expect.extend(
/**
* @note Although annotated, you don't have to declare matchers.
* You can call `expect.extend()` multiple times or get the matcher
* declarations from a third-party library.
*/
{},
)
})