Skip to content

Commit 10837e7

Browse files
committed
feat: Add type inference to parameters of 'have been called with' functions (#15034)
1 parent c54bccd commit 10837e7

File tree

9 files changed

+895
-8
lines changed

9 files changed

+895
-8
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
- `[jest-environment-jsdom]` [**BREAKING**] Upgrade JSDOM to v22 ([#13825](https://github.com/jestjs/jest/pull/13825))
2121
- `[@jest/environment-jsdom-abstract]` Introduce new package which abstracts over the `jsdom` environment, allowing usage of custom versions of JSDOM ([#14717](https://github.com/jestjs/jest/pull/14717))
2222
- `[jest-environment-node]` Update jest environment with dispose symbols `Symbol` ([#14888](https://github.com/jestjs/jest/pull/14888) & [#14909](https://github.com/jestjs/jest/pull/14909))
23+
- `[expect, @jest/expect]` [**BREAKING**] Add type inference for function parameters in `CalledWith` assertions ([#15129](https://github.com/facebook/jest/pull/15129))
2324
- `[@jest/fake-timers]` [**BREAKING**] Upgrade `@sinonjs/fake-timers` to v11 ([#14544](https://github.com/jestjs/jest/pull/14544))
2425
- `[@jest/fake-timers]` Exposing new modern timers function `advanceTimersToFrame()` which advances all timers by the needed milliseconds to execute callbacks currently scheduled with `requestAnimationFrame` ([#14598](https://github.com/jestjs/jest/pull/14598))
2526
- `[jest-matcher-utils]` Add `SERIALIZABLE_PROPERTIES` to allow custom serialization of objects ([#14893](https://github.com/jestjs/jest/pull/14893))

packages/expect/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
"@fast-check/jest": "^1.3.0",
3232
"@jest/test-utils": "workspace:*",
3333
"chalk": "^4.0.0",
34-
"immutable": "^4.0.0"
34+
"immutable": "^4.0.0",
35+
"jest-mock": "workspace:*"
3536
},
3637
"engines": {
3738
"node": "^16.10.0 || ^18.12.0 || >=20.0.0"

packages/expect/src/types.ts

Lines changed: 129 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import type {EqualsFunction, Tester} from '@jest/expect-utils';
1010
import type * as jestMatcherUtils from 'jest-matcher-utils';
11+
import type {Mock} from 'jest-mock';
1112
import type {INTERNAL_MATCHER_FLAG} from './jestMatchersObject';
1213

1314
export type SyncExpectationResult = {
@@ -231,16 +232,16 @@ export interface Matchers<R extends void | Promise<void>, T = unknown> {
231232
/**
232233
* Ensure that a mock function is called with specific arguments.
233234
*/
234-
toHaveBeenCalledWith(...expected: Array<unknown>): R;
235+
toHaveBeenCalledWith(...expected: MockParameters<T>): R;
235236
/**
236237
* Ensure that a mock function is called with specific arguments on an Nth call.
237238
*/
238-
toHaveBeenNthCalledWith(nth: number, ...expected: Array<unknown>): R;
239+
toHaveBeenNthCalledWith(nth: number, ...expected: MockParameters<T>): R;
239240
/**
240241
* If you have a mock function, you can use `.toHaveBeenLastCalledWith`
241242
* to test what arguments it was last called with.
242243
*/
243-
toHaveBeenLastCalledWith(...expected: Array<unknown>): R;
244+
toHaveBeenLastCalledWith(...expected: MockParameters<T>): R;
244245
/**
245246
* Use to test the specific value that a mock function last returned.
246247
* If the last call to the mock function threw an error, then this matcher will fail
@@ -307,3 +308,128 @@ export interface Matchers<R extends void | Promise<void>, T = unknown> {
307308
*/
308309
toThrow(expected?: unknown): R;
309310
}
311+
312+
/**
313+
* Obtains the parameters of the given {@link Mock}'s function type.
314+
* ```ts
315+
* type P = MockParameters<Mock<(foo: number) => void>>;
316+
*
317+
* const params1: P = [1]; // compiles
318+
* const params2: P = ['bar']; // error
319+
* const params3: P = []; // error
320+
* ```
321+
*
322+
* This is similar to {@link Parameters}, with these notable differences:
323+
*
324+
* 1. Each of the parameters can also accept an {@link AsymmetricMatcher}.
325+
* ```ts
326+
* const params4: P = [expect.anything()]; // compiles
327+
* ```
328+
* This works with nested types as well:
329+
* ```ts
330+
* type Nested = MockParameters<Mock<(foo: { a: number }, bar: [string]) => void>>;
331+
*
332+
* const params1: Nested = [{ foo: { a: 1 }}, ['value']]; // compiles
333+
* const params2: Nested = [expect.anything(), expect.anything()]; // compiles
334+
* const params3: Nested = [{ foo: { a: expect.anything() }}, [expect.anything()]]; // compiles
335+
* ```
336+
*
337+
* 2. This type works with overloaded functions (up to 15 overloads):
338+
* ```ts
339+
* function overloaded(): void;
340+
* function overloaded(foo: number): void;
341+
* function overloaded(foo: number, bar: string): void;
342+
* function overloaded(foo?: number, bar?: string): void {}
343+
*
344+
* type Overloaded = MockParameters<Mock<typeof overloaded>>;
345+
*
346+
* const params1: Overloaded = []; // compiles
347+
* const params2: Overloaded = [1]; // compiles
348+
* const params3: Overloaded = [1, 'value']; // compiles
349+
* const params4: Overloaded = ['value']; // error
350+
* const params5: Overloaded = ['value', 1]; // error
351+
* ```
352+
*
353+
* Mocks generated with the default `Mock` type will evaluate to `Array<unknown>`:
354+
* ```ts
355+
* MockParameters<Mock> // Array<unknown>
356+
* ```
357+
*
358+
* If the given type is not a `Mock`, this type will evaluate to `Array<unknown>`:
359+
* ```ts
360+
* MockParameters<() => void> // Array<unknown>
361+
* ```
362+
*/
363+
type MockParameters<M> =
364+
MockParametersInternal<M> extends never
365+
? Array<unknown>
366+
: MockParametersInternal<M>;
367+
368+
/**
369+
* 1. If `M` is not a `Mock` -> `never`.
370+
* 2. If the mock function is overloaded or has no parameters -> overloaded form (union of tuples).
371+
* 3. If the mock function has parameters -> simple form.
372+
* 4. else -> `never`.
373+
*/
374+
type MockParametersInternal<M> =
375+
M extends Mock<infer F>
376+
? F extends {
377+
(...args: infer P1): any;
378+
(...args: infer P2): any;
379+
(...args: infer P3): any;
380+
(...args: infer P4): any;
381+
(...args: infer P5): any;
382+
(...args: infer P6): any;
383+
(...args: infer P7): any;
384+
(...args: infer P8): any;
385+
(...args: infer P9): any;
386+
(...args: infer P10): any;
387+
(...args: infer P11): any;
388+
(...args: infer P12): any;
389+
(...args: infer P13): any;
390+
(...args: infer P14): any;
391+
(...args: infer P15): any;
392+
}
393+
?
394+
| WithAsymmetricMatchers<P1>
395+
| WithAsymmetricMatchers<P2>
396+
| WithAsymmetricMatchers<P3>
397+
| WithAsymmetricMatchers<P4>
398+
| WithAsymmetricMatchers<P5>
399+
| WithAsymmetricMatchers<P6>
400+
| WithAsymmetricMatchers<P7>
401+
| WithAsymmetricMatchers<P8>
402+
| WithAsymmetricMatchers<P9>
403+
| WithAsymmetricMatchers<P10>
404+
| WithAsymmetricMatchers<P11>
405+
| WithAsymmetricMatchers<P12>
406+
| WithAsymmetricMatchers<P13>
407+
| WithAsymmetricMatchers<P14>
408+
| WithAsymmetricMatchers<P15>
409+
: F extends (...args: infer P) => any
410+
? WithAsymmetricMatchers<P>
411+
: never
412+
: never;
413+
414+
/**
415+
* The condition here "catches" the parameters of the default `Mock` type ({@link UnknownFunction}),
416+
* evaluating to `never`, and later "wrapped" by `MockParameters` and finally evaluates to `Array<unknown>`.
417+
*/
418+
type WithAsymmetricMatchers<P extends Array<any>> =
419+
Array<unknown> extends P
420+
? never
421+
: {[K in keyof P]: DeepAsymmetricMatcher<P[K]>};
422+
423+
/**
424+
* Replaces `T` with `T | AsymmetricMatcher`.
425+
*
426+
* If `T` is an object or an array, recursively replaces all nested types with the same logic:
427+
* ```ts
428+
* type DeepAsymmetricMatcher<boolean>; // AsymmetricMatcher | boolean
429+
* type DeepAsymmetricMatcher<{ foo: number }>; // AsymmetricMatcher | { foo: AsymmetricMatcher | number }
430+
* type DeepAsymmetricMatcher<[string]>; // AsymmetricMatcher | [AsymmetricMatcher | string]
431+
* ```
432+
*/
433+
type DeepAsymmetricMatcher<T> = T extends object
434+
? AsymmetricMatcher | {[K in keyof T]: DeepAsymmetricMatcher<T[K]>}
435+
: AsymmetricMatcher | T;

packages/expect/tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
{"path": "../jest-get-type"},
1313
{"path": "../jest-matcher-utils"},
1414
{"path": "../jest-message-util"},
15+
{"path": "../jest-mock"},
1516
{"path": "../jest-util"}
1617
]
1718
}

packages/jest-snapshot/src/__tests__/throwMatcher.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77

88
import {type Context, toThrowErrorMatchingSnapshot} from '../';
99

10-
const mockedMatch = jest.fn(() => ({
10+
const mockedMatch = jest.fn<
11+
(args: {received: string; testName: string}) => unknown
12+
>(() => ({
1113
actual: 'coconut',
1214
expected: 'coconut',
1315
}));

0 commit comments

Comments
 (0)