Skip to content

Commit

Permalink
feat: Add type inference to parameters of 'have been called with' fun…
Browse files Browse the repository at this point in the history
…ctions (jestjs#15034)
  • Loading branch information
eyalroth committed Jun 22, 2024
1 parent c54bccd commit 7d3c48a
Show file tree
Hide file tree
Showing 8 changed files with 893 additions and 7 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
- `[jest-environment-jsdom]` [**BREAKING**] Upgrade JSDOM to v22 ([#13825](https://github.com/jestjs/jest/pull/13825))
- `[@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))
- `[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))
- `[expect, @jest/expect]` [**BREAKING**] Add type inference for function parameters in `CalledWith` assertions ([#15129](https://github.com/facebook/jest/pull/15129))
- `[@jest/fake-timers]` [**BREAKING**] Upgrade `@sinonjs/fake-timers` to v11 ([#14544](https://github.com/jestjs/jest/pull/14544))
- `[@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))
- `[jest-matcher-utils]` Add `SERIALIZABLE_PROPERTIES` to allow custom serialization of objects ([#14893](https://github.com/jestjs/jest/pull/14893))
Expand Down
1 change: 1 addition & 0 deletions packages/expect/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"devDependencies": {
"@fast-check/jest": "^1.3.0",
"@jest/test-utils": "workspace:*",
"jest-mock": "workspace:*",
"chalk": "^4.0.0",
"immutable": "^4.0.0"
},
Expand Down
132 changes: 129 additions & 3 deletions packages/expect/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

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

export type SyncExpectationResult = {
Expand Down Expand Up @@ -231,16 +232,16 @@ export interface Matchers<R extends void | Promise<void>, T = unknown> {
/**
* Ensure that a mock function is called with specific arguments.
*/
toHaveBeenCalledWith(...expected: Array<unknown>): R;
toHaveBeenCalledWith(...expected: MockParameters<T>): R;
/**
* Ensure that a mock function is called with specific arguments on an Nth call.
*/
toHaveBeenNthCalledWith(nth: number, ...expected: Array<unknown>): R;
toHaveBeenNthCalledWith(nth: number, ...expected: MockParameters<T>): R;
/**
* If you have a mock function, you can use `.toHaveBeenLastCalledWith`
* to test what arguments it was last called with.
*/
toHaveBeenLastCalledWith(...expected: Array<unknown>): R;
toHaveBeenLastCalledWith(...expected: MockParameters<T>): R;
/**
* Use to test the specific value that a mock function last returned.
* If the last call to the mock function threw an error, then this matcher will fail
Expand Down Expand Up @@ -307,3 +308,128 @@ export interface Matchers<R extends void | Promise<void>, T = unknown> {
*/
toThrow(expected?: unknown): R;
}

/**
* Obtains the parameters of the given {@link Mock}'s function type.
* ```ts
* type P = MockParameters<Mock<(foo: number) => void>>;
*
* const params1: P = [1]; // compiles
* const params2: P = ['bar']; // error
* const params3: P = []; // error
* ```
*
* This is similar to {@link Parameters}, with these notable differences:
*
* 1. Each of the parameters can also accept an {@link AsymmetricMatcher}.
* ```ts
* const params4: P = [expect.anything()]; // compiles
* ```
* This works with nested types as well:
* ```ts
* type Nested = MockParameters<Mock<(foo: { a: number }, bar: [string]) => void>>;
*
* const params1: Nested = [{ foo: { a: 1 }}, ['value']]; // compiles
* const params2: Nested = [expect.anything(), expect.anything()]; // compiles
* const params3: Nested = [{ foo: { a: expect.anything() }}, [expect.anything()]]; // compiles
* ```
*
* 2. This type works with overloaded functions (up to 15 overloads):
* ```ts
* function overloaded(): void;
* function overloaded(foo: number): void;
* function overloaded(foo: number, bar: string): void;
* function overloaded(foo?: number, bar?: string): void {}
*
* type Overloaded = MockParameters<Mock<typeof overloaded>>;
*
* const params1: Overloaded = []; // compiles
* const params2: Overloaded = [1]; // compiles
* const params3: Overloaded = [1, 'value']; // compiles
* const params4: Overloaded = ['value']; // error
* const params5: Overloaded = ['value', 1]; // error
* ```
*
* Mocks generated with the default `Mock` type will evaluate to `Array<unknown>`:
* ```ts
* MockParameters<Mock> // Array<unknown>
* ```
*
* If the given type is not a `Mock`, this type will evaluate to `Array<unknown>`:
* ```ts
* MockParameters<() => void> // Array<unknown>
* ```
*/
type MockParameters<M> =
MockParametersInternal<M> extends never
? Array<unknown>
: MockParametersInternal<M>;

/**
* 1. If `M` is not a `Mock` -> `never`.
* 2. If the mock function is overloaded or has no parameters -> overloaded form (union of tuples).
* 3. If the mock function has parameters -> simple form.
* 4. else -> `never`.
*/
type MockParametersInternal<M> =
M extends Mock<infer F>
? F extends {
(...args: infer P1): any;
(...args: infer P2): any;
(...args: infer P3): any;
(...args: infer P4): any;
(...args: infer P5): any;
(...args: infer P6): any;
(...args: infer P7): any;
(...args: infer P8): any;
(...args: infer P9): any;
(...args: infer P10): any;
(...args: infer P11): any;
(...args: infer P12): any;
(...args: infer P13): any;
(...args: infer P14): any;
(...args: infer P15): any;
}
?
| WithAsymmetricMatchers<P1>
| WithAsymmetricMatchers<P2>
| WithAsymmetricMatchers<P3>
| WithAsymmetricMatchers<P4>
| WithAsymmetricMatchers<P5>
| WithAsymmetricMatchers<P6>
| WithAsymmetricMatchers<P7>
| WithAsymmetricMatchers<P8>
| WithAsymmetricMatchers<P9>
| WithAsymmetricMatchers<P10>
| WithAsymmetricMatchers<P11>
| WithAsymmetricMatchers<P12>
| WithAsymmetricMatchers<P13>
| WithAsymmetricMatchers<P14>
| WithAsymmetricMatchers<P15>
: F extends (...args: infer P) => any
? WithAsymmetricMatchers<P>
: never
: never;

/**
* The condition here "catches" the parameters of the default `Mock` type ({@link UnknownFunction}),
* evaluating to `never`, and later "wrapped" by `MockParameters` and finally evaluates to `Array<unknown>`.
*/
type WithAsymmetricMatchers<P extends Array<any>> =
Array<unknown> extends P
? never
: {[K in keyof P]: DeepAsymmetricMatcher<P[K]>};

/**
* Replaces `T` with `T | AsymmetricMatcher`.
*
* If `T` is an object or an array, recursively replaces all nested types with the same logic:
* ```ts
* type DeepAsymmetricMatcher<boolean>; // AsymmetricMatcher | boolean
* type DeepAsymmetricMatcher<{ foo: number }>; // AsymmetricMatcher | { foo: AsymmetricMatcher | number }
* type DeepAsymmetricMatcher<[string]>; // AsymmetricMatcher | [AsymmetricMatcher | string]
* ```
*/
type DeepAsymmetricMatcher<T> = T extends object
? AsymmetricMatcher | {[K in keyof T]: DeepAsymmetricMatcher<T[K]>}
: AsymmetricMatcher | T;
1 change: 1 addition & 0 deletions packages/expect/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
{"path": "../jest-get-type"},
{"path": "../jest-matcher-utils"},
{"path": "../jest-message-util"},
{"path": "../jest-mock"},
{"path": "../jest-util"}
]
}
4 changes: 3 additions & 1 deletion packages/jest-snapshot/src/__tests__/throwMatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@

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

const mockedMatch = jest.fn(() => ({
const mockedMatch = jest.fn<
(args: {received: string; testName: string}) => unknown
>(() => ({
actual: 'coconut',
expected: 'coconut',
}));
Expand Down
Loading

0 comments on commit 7d3c48a

Please sign in to comment.