Skip to content

Commit 41f551b

Browse files
feat(types)!: add better promise support in expects and matchers (#8266)
Co-authored-by: Vladimir Sheremet <[email protected]>
1 parent 46a3cf7 commit 41f551b

23 files changed

Lines changed: 338 additions & 218 deletions

File tree

‎docs/api/expect.md‎

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@ type Awaitable<T> = T | PromiseLike<T>
99
`expect` is used to create assertions. In this context `assertions` are functions that can be called to assert a statement. Vitest provides `chai` assertions by default and also `Jest` compatible assertions built on top of `chai`. Since Vitest 4.1, for spy/mock testing, Vitest also provides Chai-style assertions (e.g., [`expect(spy).to.have.been.called()`](#called)) alongside Jest-style assertions (e.g., `expect(spy).toHaveBeenCalled()`). Unlike `Jest`, Vitest supports a message as the second argument - if the assertion fails, the error message will be equal to it.
1010
1111
```ts
12-
export interface ExpectStatic extends Chai.ExpectStatic, AsymmetricMatchersContaining {
13-
<T>(actual: T, message?: string): Assertion<T>
12+
export interface ExpectStatic
13+
extends Chai.ExpectStatic,
14+
Matchers<any>,
15+
AsymmetricMatchersContaining {
16+
<T>(actual: T, message?: string): Assertion<void, T>
1417
extend: (expects: MatchersObject) => void
1518
anything: () => any
1619
any: (constructor: unknown) => any
@@ -2242,12 +2245,11 @@ import { expect, test } from 'vitest'
22422245

22432246
test('custom matchers', () => {
22442247
expect.extend({
2245-
toBeFoo: (received, expected) => {
2246-
if (received !== 'foo') {
2247-
return {
2248-
message: () => `expected ${received} to be foo`,
2249-
pass: false,
2250-
}
2248+
toBeFoo(received) {
2249+
const { isNot } = this
2250+
return {
2251+
message: () => `expected ${received} is${isNot ? ' not' : ''} foo`,
2252+
pass: received === 'foo',
22512253
}
22522254
},
22532255
})
@@ -2263,19 +2265,20 @@ If you want your matchers to appear in every test, you should call this method i
22632265

22642266
This function is compatible with Jest's `expect.extend`, so any library that uses it to create custom matchers will work with Vitest.
22652267

2266-
If you are using TypeScript, since Vitest 0.31.0 you can extend default `Assertion` interface in an ambient declaration file (e.g: `vitest.d.ts`) with the code below:
2268+
If you are using TypeScript, you can extend the default `Matchers` interface in an ambient declaration file (e.g: `vitest.d.ts`) with the code below:
22672269

22682270
```ts
2269-
interface CustomMatchers<R = unknown> {
2270-
toBeFoo: () => R
2271-
}
2271+
import 'vitest'
22722272

22732273
declare module 'vitest' {
2274-
interface Assertion<T = any> extends CustomMatchers<T> {}
2275-
interface AsymmetricMatchersContaining extends CustomMatchers {}
2274+
interface Matchers<R, T> {
2275+
toBeFoo: () => R
2276+
}
22762277
}
22772278
```
22782279

2280+
`R` is the assertion return type, and `T` is the type of the received value.
2281+
22792282
::: warning
22802283
Don't forget to include the ambient declaration file in your `tsconfig.json`.
22812284
:::

‎docs/blog/vitest-3-2.md‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ Vitest now has a `Matchers` type that you can extend to add type support for all
239239

240240
For example, to have a type-safe `toBeFoo` matcher, you can write something like this:
241241

242-
```ts twoslash
242+
```ts
243243
import { expect } from 'vitest'
244244

245245
interface CustomMatchers<R = unknown> {
@@ -252,7 +252,6 @@ declare module 'vitest' {
252252

253253
expect.extend({
254254
toBeFoo(actual, arg) {
255-
// ^?
256255
// ... implementation
257256
return {
258257
pass: true,

‎docs/guide/extending-matchers.md‎

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ To extend default matchers, call `expect.extend` with an object containing your
1212

1313
```ts
1414
expect.extend({
15-
toBeFoo(received, expected) {
15+
toBeFoo(received) {
1616
const { isNot } = this
1717
return {
1818
// do not alter your "pass" based on isNot. Vitest does it for you
@@ -29,12 +29,24 @@ If you are using TypeScript, you can extend default `Matchers` interface in an a
2929
import 'vitest'
3030

3131
declare module 'vitest' {
32-
interface Matchers<T = any> {
32+
interface Matchers<R, T> {
3333
toBeFoo: () => R
3434
}
3535
}
3636
```
3737

38+
`R` is the assertion return type, and `T` is the type of the received value.
39+
40+
Return `R` from matchers that run synchronously. This makes the return type `void` for a regular assertion and `Promise<void>` when the assertion is used with `.resolves`, `.rejects`, [`expect.poll`](/api/expect#poll), or [`expect.element`](/api/browser/assertions). You can use `T` when an expected argument should have the same type as the received value:
41+
42+
```ts
43+
declare module 'vitest' {
44+
interface Matchers<R, T> {
45+
toEqualTyped: (expected: T) => R
46+
}
47+
}
48+
```
49+
3850
::: tip
3951
Importing `vitest` makes TypeScript think this is an ES module file, type declaration won't work without it.
4052
:::
@@ -45,30 +57,42 @@ Extending the `Matchers` interface will add a type to `expect.extend`, `expect()
4557
Don't forget to include the ambient declaration file in your `tsconfig.json`.
4658
:::
4759

48-
The return value of a matcher should be compatible with the following interface:
60+
The return value of a matcher should be compatible with the following types:
4961

5062
```ts
51-
interface MatcherResult {
63+
interface SyncMatcherResult {
5264
pass: boolean
5365
message: () => string
5466
// If you pass these, they will automatically appear inside a diff when
5567
// the matcher does not pass, so you don't need to print the diff yourself
5668
actual?: unknown
5769
expected?: unknown
70+
meta?: object
5871
}
72+
73+
type MatcherResult = SyncMatcherResult | Promise<SyncMatcherResult>
5974
```
6075
6176
::: warning
62-
If you create an asynchronous matcher, don't forget to `await` the result (`await expect('foo').toBeFoo()`) in the test itself:
77+
If a matcher implementation is asynchronous, declare its return type as `Promise<void>` instead of `R` and don't forget to `await` it in the test:
6378
6479
```ts
6580
expect.extend({
66-
async toBeAsyncAssertion() {
67-
// ...
81+
async toBeAsyncAssertion(received) {
82+
return {
83+
pass: received === 'foo',
84+
message: () => `expected ${received} to be foo`,
85+
}
6886
}
6987
})
7088

71-
await expect().toBeAsyncAssertion()
89+
declare module 'vitest' {
90+
interface Matchers<R, T> {
91+
toBeAsyncAssertion: () => Promise<void>
92+
}
93+
}
94+
95+
await expect('foo').toBeAsyncAssertion()
7296
```
7397
:::
7498

@@ -155,7 +179,7 @@ The name of the current [`environment`](/config/environment) (for example, `jsdo
155179

156180
Was assertion called as a [`soft`](/api/expect#soft) one. You don't need to respect it, Vitest will always catch the error.
157181

158-
## `assertion` <Advanced /> <Version type="experimental">4.1.4</Version> {#assertion}
182+
## `assertion` <Advanced /> <Version>5.0.0</Version> {#assertion}
159183

160184
The underlying [Chai assertion](https://www.chaijs.com/guide/plugins/) object. This is the same instance that Chai plugins receive, giving you access to Chai's flag system and chainable methods. This can be useful for building custom matchers that need to interact with Chai's internals.
161185

‎docs/guide/migration.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,43 @@ To assert that a thrown error has an empty message, match the pattern explicitly
225225
expect(() => { throw new Error('boom') }).not.toThrow(/^$/)
226226
```
227227

228+
### Assertion Types Expose Return and Received Types
229+
230+
Assertion interfaces now use two type parameters: `R` is the matcher return type and `T` is the received value type. Synchronous assertions use `void`, while assertions accessed through `.resolves`, `.rejects`, [`expect.poll`](/api/expect#poll), or [`expect.element`](/api/browser/assertions) use `Promise<void>`.
231+
232+
If you declare custom matchers, augment the `Matchers<R, T>` interface. It adds the matcher to instance assertions, asymmetric matchers, and the type accepted by `expect.extend`:
233+
234+
```ts [vitest.d.ts]
235+
import 'vitest'
236+
237+
interface CustomMatchers<R = unknown, T = unknown> {
238+
toBeFoo: () => R
239+
toEqualTyped: (expected: T) => R
240+
}
241+
242+
declare module 'vitest' {
243+
interface Matchers<R, T> extends CustomMatchers<R, T> {}
244+
}
245+
```
246+
247+
This makes custom matcher return types reflect how the matcher is used:
248+
249+
```ts
250+
const syncResult = expect('value').toEqualTyped('other') // void
251+
const asyncResult = expect(Promise.resolve('value')).resolves.toEqualTyped('other') // Promise<void>
252+
await asyncResult
253+
```
254+
255+
Code that refers to assertion types directly must also provide the return type first:
256+
257+
```ts
258+
Assertion<string> // [!code --]
259+
Assertion<void, string> // [!code ++]
260+
Assertion<Promise<void>, string> // asynchronous assertion
261+
```
262+
263+
Vitest no longer reads custom matcher declarations from the global `jest.Matchers` interface. Libraries that support both Jest and Vitest should augment `jest.Matchers` and `vitest.Matchers` separately. This only affects TypeScript declarations; registering matchers with `expect.extend` works as before.
264+
228265
### `expect.poll` Fails When It Times Out
229266

230267
[`expect.poll`](/api/expect#poll) now rejects when its callback, or the polled assertion, does not settle within `timeout`. Previously a callback that resolved after the deadline, or an assertion that only passed on a late attempt, could still succeed. The callback now also receives an `AbortSignal` that aborts when the timeout elapses, so you can cancel in-flight work:

‎docs/guide/snapshot.md‎

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -246,8 +246,8 @@ import { expect, test, Snapshots } from 'vitest'
246246
const { toMatchFileSnapshot, toMatchInlineSnapshot, toMatchSnapshot } = Snapshots
247247

248248
expect.extend({
249-
toMatchTrimmedSnapshot(received: string) {
250-
return toMatchSnapshot.call(this, received.slice(0, 10))
249+
toMatchTrimmedSnapshot(received: string, length: number) {
250+
return toMatchSnapshot.call(this, received.slice(0, length))
251251
},
252252
toMatchTrimmedInlineSnapshot(received: string, inlineSnapshot?: string) {
253253
return toMatchInlineSnapshot.call(this, received.slice(0, 10), inlineSnapshot)
@@ -298,7 +298,7 @@ File snapshot matchers must be `async` — `toMatchFileSnapshot` returns a `Prom
298298
:::
299299

300300
::: warning
301-
When custom inline snapshot matcher is aynchronous, Vitest cannot automatically infer the call location for inline snapshot rewriting. You must capture the call site by setting the `'error'` flag on the chai assertion object:
301+
When custom inline snapshot matcher is asynchronous, Vitest cannot automatically infer the call location for inline snapshot rewriting. You must capture the call site by setting the `'error'` flag on the chai assertion object:
302302

303303
```ts
304304
import { expect, chai, Snapshots } from 'vitest'
@@ -317,16 +317,16 @@ expect.extend({
317317

318318
:::
319319

320-
For TypeScript, extend the `Assertion` interface:
320+
For TypeScript, augment the `Matchers<R, T>` interface:
321321

322322
```ts
323323
import 'vitest'
324324

325325
declare module 'vitest' {
326-
interface Assertion<T = any> {
327-
toMatchTrimmedSnapshot: (length: number) => T
328-
toMatchTrimmedInlineSnapshot: (inlineSnapshot?: string) => T
329-
toMatchTrimmedFileSnapshot: (file: string) => Promise<T>
326+
interface Matchers<R, T> {
327+
toMatchTrimmedSnapshot: (length: number) => R
328+
toMatchTrimmedInlineSnapshot: (inlineSnapshot?: string) => R
329+
toMatchTrimmedFileSnapshot: (file: string) => Promise<void>
330330
}
331331
}
332332
```
@@ -391,14 +391,21 @@ This asymmetry is what makes `--update` work correctly: `match` returns a `resol
391391
Register a custom matcher with `expect.extend(...)` and call the snapshot composables from `vitest`:
392392
393393
```ts [setup.ts]
394-
import { expect, Snaphsots } from 'vitest'
394+
import { expect, Snapshots } from 'vitest'
395+
396+
declare module 'vitest' {
397+
interface Matchers<R, T> {
398+
toMatchMyDomainSnapshot: () => R
399+
toMatchMyDomainInlineSnapshot: (inlineSnapshot?: string) => R
400+
}
401+
}
395402

396403
expect.extend({
397404
toMatchMyDomainSnapshot(received: unknown) {
398-
return Snaphsots.toMatchDomainSnapshot.call(this, myAdapter, received)
405+
return Snapshots.toMatchDomainSnapshot.call(this, myAdapter, received)
399406
},
400407
toMatchMyDomainInlineSnapshot(received: unknown, inlineSnapshot?: string) {
401-
return Snaphsots.toMatchDomainInlineSnapshot.call(
408+
return Snapshots.toMatchDomainInlineSnapshot.call(
402409
this,
403410
myAdapter,
404411
received,
@@ -494,6 +501,13 @@ export const kvAdapter: DomainSnapshotAdapter<KVCaptured, KVExpected> = {
494501
import { expect, Snapshots } from 'vitest'
495502
import { kvAdapter } from './kv-adapter'
496503

504+
declare module 'vitest' {
505+
interface Matchers<R, T> {
506+
toMatchKvSnapshot: () => R
507+
toMatchKvInlineSnapshot: (inlineSnapshot?: string) => R
508+
}
509+
}
510+
497511
expect.extend({
498512
toMatchKvSnapshot(received: unknown) {
499513
return Snapshots.toMatchDomainSnapshot.call(this, kvAdapter, received)

‎packages/browser/jest-dom.d.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { ARIARole } from './aria-role.js'
44
import { Locator, ScreenshotComparatorRegistry, ScreenshotMatcherOptions } from './context.js'
55

6-
export interface TestingLibraryMatchers<E, R> {
6+
export interface TestingLibraryMatchers<R extends void | Promise<void>, T = unknown> {
77
/**
88
* @description
99
* Assert whether an element is present in the document or not.
@@ -488,7 +488,7 @@ export interface TestingLibraryMatchers<E, R> {
488488
* await expect.element(page.getByTestId('logo')).toHaveAccessibleDescription('The logo of Our Company')
489489
* @see https://vitest.dev/api/browser/assertions#tohaveaccessibledescription
490490
*/
491-
toHaveAccessibleDescription(text?: string | RegExp | E): R
491+
toHaveAccessibleDescription(text?: string | RegExp | T): R
492492

493493
/**
494494
* @description
@@ -527,7 +527,7 @@ export interface TestingLibraryMatchers<E, R> {
527527
*
528528
* @see https://vitest.dev/api/browser/assertions#tohaveaccessibleerrormessage
529529
*/
530-
toHaveAccessibleErrorMessage(text?: string | RegExp | E): R
530+
toHaveAccessibleErrorMessage(text?: string | RegExp | T): R
531531

532532
/**
533533
* @description
@@ -558,7 +558,7 @@ export interface TestingLibraryMatchers<E, R> {
558558
* await expect.element(page.getByTestId('input-title')).toHaveAccessibleName()
559559
* @see https://vitest.dev/api/browser/assertions#tohaveaccessiblename
560560
*/
561-
toHaveAccessibleName(text?: string | RegExp | E): R
561+
toHaveAccessibleName(text?: string | RegExp | T): R
562562
/**
563563
* @description
564564
* This allows you to assert that an element has the expected

‎packages/browser/matchers.d.ts‎

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,7 @@ import type { TestingLibraryMatchers } from './jest-dom.js'
33
import type { Assertion, ExpectPollOptions } from 'vitest'
44

55
declare module 'vitest' {
6-
interface JestAssertion<T = any> extends TestingLibraryMatchers<void, T> {}
7-
interface AsymmetricMatchersContaining extends TestingLibraryMatchers<void, void> {}
8-
9-
type Promisify<O> = {
10-
[K in keyof O]: O[K] extends (...args: infer A) => infer R
11-
? O extends R
12-
? Promisify<O[K]>
13-
: (...args: A) => Promise<R>
14-
: O[K];
15-
}
16-
17-
type PromisifyDomAssertion<T> = Promisify<Assertion<T>>
6+
interface Assertion<R, T> extends TestingLibraryMatchers<R, T> {}
187

198
interface ExpectStatic {
209
/**
@@ -23,7 +12,10 @@ declare module 'vitest' {
2312
* You can set default timeout via `expect.poll.timeout` option in the config.
2413
* @see {@link https://vitest.dev/api/expect#poll}
2514
*/
26-
element: <T extends HTMLElement | SVGElement | null | Locator>(element: T, options?: ExpectPollOptions) => PromisifyDomAssertion<Awaited<HTMLElement | SVGElement | null>>
15+
element: <T extends HTMLElement | SVGElement | null | Locator>(element: T, options?: ExpectPollOptions) => Assertion<
16+
Promise<void>,
17+
HTMLElement | SVGElement | null
18+
>
2719
}
2820
}
2921

‎packages/browser/src/client/tester/expect-element.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Assertion, ExpectPollOptions, PromisifyDomAssertion } from 'vitest'
1+
import type { Assertion, ExpectPollOptions } from 'vitest'
22
import type { Locator } from 'vitest/browser'
33
import type { BrowserTraceEntryStatus } from './trace'
44
import { chai, expect } from 'vitest'
@@ -11,7 +11,7 @@ import { createBrowserTraceRangeId, recordBrowserTraceEntry } from './trace'
1111

1212
const kLocator = Symbol.for('$$vitest:locator')
1313

14-
function element<T extends HTMLElement | SVGElement | null | Locator>(elementOrLocator: T, options?: ExpectPollOptions): PromisifyDomAssertion<HTMLElement | SVGElement | null> {
14+
function element<T extends HTMLElement | SVGElement | null | Locator>(elementOrLocator: T, options?: ExpectPollOptions): Assertion<Promise<void>, HTMLElement | SVGElement | null> {
1515
if (elementOrLocator != null && !(elementOrLocator instanceof HTMLElement) && !(elementOrLocator instanceof SVGElement) && !(kLocator in elementOrLocator)) {
1616
throw new Error(`Invalid element or locator: ${elementOrLocator}. Expected an instance of HTMLElement, SVGElement or Locator, received ${getType(elementOrLocator)}`)
1717
}

0 commit comments

Comments
 (0)