[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

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

Co-authored-by: Vladimir Sheremet <sleuths.slews0s@icloud.com>

authored by

Artem Zakharchenko
Vladimir Sheremet
and committed by
GitHub
(May 19, 2025, 3:12 PM +0200) e996b410 2854ad66

+108 -19
+42 -15
docs/guide/extending-matchers.md
··· 25 25 26 26 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: 27 27 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>] 29 41 import 'vitest' 30 42 31 43 interface CustomMatchers<R = unknown> { ··· 37 49 interface AsymmetricMatchersContaining extends CustomMatchers {} 38 50 } 39 51 ``` 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 + ::: 40 57 41 58 ::: warning 42 59 Don't forget to include the ambient declaration file in your `tsconfig.json`. ··· 56 73 ``` 57 74 58 75 ::: 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 + ``` 60 87 ::: 61 88 62 89 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. 63 90 64 - Matcher function have access to `this` context with the following properties: 91 + Matcher function has access to `this` context with the following properties: 65 92 66 - - `isNot` 93 + ### `isNot` 67 94 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()`). 69 96 70 - - `promise` 97 + ### `promise` 71 98 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. 73 100 74 - - `equals` 101 + ### `equals` 75 102 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. 77 104 78 - - `utils` 105 + ### `utils` 79 106 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. 81 108 82 109 `this` context also contains information about the current test. You can also get it by calling `expect.getState()`. The most useful properties are: 83 110 84 - - `currentTestName` 111 + ### `currentTestName` 85 112 86 - Full name of the current test (including describe block). 113 + Full name of the current test (including describe block). 87 114 88 - - `testPath` 115 + ### `testPath` 89 116 90 - Path to the current test. 117 + Path to the current test.
+13 -4
packages/expect/src/types.ts
··· 86 86 87 87 export type ExpectationResult = SyncExpectationResult | AsyncExpectationResult 88 88 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 91 91 } 92 + 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> {} 92 97 93 98 export type MatchersObject<T extends MatcherState = MatcherState> = Record< 94 99 string, 95 100 RawMatcherFn<T> 96 - > & ThisType<T> 101 + > & ThisType<T> & { 102 + [K in keyof Matchers<T>]?: RawMatcherFn<T, Parameters<Matchers<T>[K]>> 103 + } 97 104 98 105 export interface ExpectStatic 99 106 extends Chai.ExpectStatic, 107 + Matchers, 100 108 AsymmetricMatchersContaining { 101 109 <T>(actual: T, message?: string): Assertion<T> 102 110 extend: (expects: MatchersObject) => void ··· 639 647 640 648 export interface Assertion<T = any> 641 649 extends VitestAssertion<Chai.Assertion, T>, 642 - JestAssertion<T> { 650 + JestAssertion<T>, 651 + Matchers<T> { 643 652 /** 644 653 * Ensures a value is of a specific type. 645 654 *
+52
test/typescript/test-d/expect-extend.test-d.ts
··· 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 + })
+1
packages/vitest/src/public/index.ts
··· 252 252 ExpectPollOptions, 253 253 ExpectStatic, 254 254 JestAssertion, 255 + Matchers, 255 256 } from '@vitest/expect' 256 257 export { 257 258 afterAll,