Skip to content

Commit 64be9af

Browse files
committed
feat: Add type inference to parameters of 'have been called with' functions (#15034)
1 parent 5bc71bd commit 64be9af

File tree

12 files changed

+1020
-10
lines changed

12 files changed

+1020
-10
lines changed

CHANGELOG.md

+1
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

+2-1
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/__tests__/spyMatchers.test.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import * as Immutable from 'immutable';
99
import {alignedAnsiStyleSerializer} from '@jest/test-utils';
10+
import type {FunctionLike} from 'jest-mock';
1011
import jestExpect from '../';
1112

1213
expect.addSnapshotSerializer(alignedAnsiStyleSerializer);
@@ -25,7 +26,7 @@ declare module '../types' {
2526
}
2627

2728
// Given a Jest mock function, return a minimal mock of a spy.
28-
const createSpy = (fn: jest.Mock) => {
29+
const createSpy = <T extends FunctionLike>(fn: jest.Mock<T>): jest.Mock<T> => {
2930
const spy = function () {};
3031

3132
spy.calls = {
@@ -37,7 +38,7 @@ const createSpy = (fn: jest.Mock) => {
3738
},
3839
};
3940

40-
return spy;
41+
return spy as unknown as jest.Mock<T>;
4142
};
4243

4344
describe('toHaveBeenCalled', () => {

packages/expect/src/types.ts

+149-3
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 {MockInstance} 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,148 @@ 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 function or {@link MockInstance}'s function type.
314+
* ```ts
315+
* type P = MockParameters<MockInstance<(foo: number) => void>>;
316+
* // or without an explicit mock
317+
* // type P = MockParameters<(foo: number) => void>;
318+
*
319+
* const params1: P = [1]; // compiles
320+
* const params2: P = ['bar']; // error
321+
* const params3: P = []; // error
322+
* ```
323+
*
324+
* This is similar to {@link Parameters}, with these notable differences:
325+
*
326+
* 1. Each of the parameters can also accept an {@link AsymmetricMatcher}.
327+
* ```ts
328+
* const params4: P = [expect.anything()]; // compiles
329+
* ```
330+
* This works with nested types as well:
331+
* ```ts
332+
* type Nested = MockParameters<MockInstance<(foo: { a: number }, bar: [string]) => void>>;
333+
*
334+
* const params1: Nested = [{ foo: { a: 1 }}, ['value']]; // compiles
335+
* const params2: Nested = [expect.anything(), expect.anything()]; // compiles
336+
* const params3: Nested = [{ foo: { a: expect.anything() }}, [expect.anything()]]; // compiles
337+
* ```
338+
*
339+
* 2. This type works with overloaded functions (up to 15 overloads):
340+
* ```ts
341+
* function overloaded(): void;
342+
* function overloaded(foo: number): void;
343+
* function overloaded(foo: number, bar: string): void;
344+
* function overloaded(foo?: number, bar?: string): void {}
345+
*
346+
* type Overloaded = MockParameters<MockInstance<typeof overloaded>>;
347+
*
348+
* const params1: Overloaded = []; // compiles
349+
* const params2: Overloaded = [1]; // compiles
350+
* const params3: Overloaded = [1, 'value']; // compiles
351+
* const params4: Overloaded = ['value']; // error
352+
* const params5: Overloaded = ['value', 1]; // error
353+
* ```
354+
*
355+
* Mocks generated with the default `MockInstance` type will evaluate to `Array<unknown>`:
356+
* ```ts
357+
* MockParameters<MockInstance> // Array<unknown>
358+
* ```
359+
*
360+
* If the given type is not a `MockInstance` nor a function, this type will evaluate to `Array<unknown>`:
361+
* ```ts
362+
* MockParameters<boolean> // Array<unknown>
363+
* ```
364+
*/
365+
type MockParameters<M> =
366+
M extends MockInstance<infer F>
367+
? FunctionParameters<F>
368+
: FunctionParameters<M>;
369+
370+
/**
371+
* A wrapper over `FunctionParametersInternal` which converts `never` evaluations to `Array<unknown>`.
372+
*
373+
* This is only necessary for Typescript versions prior to 5.3.
374+
*
375+
* In those versions, a function without parameters (`() => any`) is interpreted the same as an overloaded function,
376+
* causing `FunctionParametersInternal` to evaluate it to `[] | Array<unknown>`, which is incorrect.
377+
*
378+
* The workaround is to "catch" this edge-case in `WithAsymmetricMatchers` and interpret it as `never`.
379+
* However, this also affects {@link UnknownFunction} (the default generic type of `MockInstance`):
380+
* ```ts
381+
* FunctionParametersInternal<() => any> // [] | never --> [] --> correct
382+
* FunctionParametersInternal<UnknownFunction> // never --> incorrect
383+
* ```
384+
* An empty array is the expected type for a function without parameters,
385+
* so all that's left is converting `never` to `Array<unknown>` for the case of `UnknownFunction`,
386+
* as it needs to accept _any_ combination of parameters.
387+
*/
388+
type FunctionParameters<F> =
389+
FunctionParametersInternal<F> extends never
390+
? Array<unknown>
391+
: FunctionParametersInternal<F>;
392+
393+
/**
394+
* 1. If the function is overloaded or has no parameters -> overloaded form (union of tuples).
395+
* 2. If the function has parameters -> simple form.
396+
* 3. else -> `never`.
397+
*/
398+
type FunctionParametersInternal<F> = F extends {
399+
(...args: infer P1): any;
400+
(...args: infer P2): any;
401+
(...args: infer P3): any;
402+
(...args: infer P4): any;
403+
(...args: infer P5): any;
404+
(...args: infer P6): any;
405+
(...args: infer P7): any;
406+
(...args: infer P8): any;
407+
(...args: infer P9): any;
408+
(...args: infer P10): any;
409+
(...args: infer P11): any;
410+
(...args: infer P12): any;
411+
(...args: infer P13): any;
412+
(...args: infer P14): any;
413+
(...args: infer P15): any;
414+
}
415+
?
416+
| WithAsymmetricMatchers<P1>
417+
| WithAsymmetricMatchers<P2>
418+
| WithAsymmetricMatchers<P3>
419+
| WithAsymmetricMatchers<P4>
420+
| WithAsymmetricMatchers<P5>
421+
| WithAsymmetricMatchers<P6>
422+
| WithAsymmetricMatchers<P7>
423+
| WithAsymmetricMatchers<P8>
424+
| WithAsymmetricMatchers<P9>
425+
| WithAsymmetricMatchers<P10>
426+
| WithAsymmetricMatchers<P11>
427+
| WithAsymmetricMatchers<P12>
428+
| WithAsymmetricMatchers<P13>
429+
| WithAsymmetricMatchers<P14>
430+
| WithAsymmetricMatchers<P15>
431+
: F extends (...args: infer P) => any
432+
? WithAsymmetricMatchers<P>
433+
: never;
434+
435+
/**
436+
* @see FunctionParameters
437+
*/
438+
type WithAsymmetricMatchers<P extends Array<any>> =
439+
Array<unknown> extends P
440+
? never
441+
: {[K in keyof P]: DeepAsymmetricMatcher<P[K]>};
442+
443+
/**
444+
* Replaces `T` with `T | AsymmetricMatcher`.
445+
*
446+
* If `T` is an object or an array, recursively replaces all nested types with the same logic:
447+
* ```ts
448+
* type DeepAsymmetricMatcher<boolean>; // AsymmetricMatcher | boolean
449+
* type DeepAsymmetricMatcher<{ foo: number }>; // AsymmetricMatcher | { foo: AsymmetricMatcher | number }
450+
* type DeepAsymmetricMatcher<[string]>; // AsymmetricMatcher | [AsymmetricMatcher | string]
451+
* ```
452+
*/
453+
type DeepAsymmetricMatcher<T> = T extends object
454+
? AsymmetricMatcher | {[K in keyof T]: DeepAsymmetricMatcher<T[K]>}
455+
: AsymmetricMatcher | T;

packages/expect/tsconfig.json

+1
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

+3-1
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)