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 (#15034)
  • Loading branch information
eyalroth committed Jun 24, 2024
1 parent 5bc71bd commit 02ea166
Show file tree
Hide file tree
Showing 12 changed files with 1,019 additions and 9 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 @@ -25,6 +25,7 @@
"jest-get-type": "workspace:*",
"jest-matcher-utils": "workspace:*",
"jest-message-util": "workspace:*",
"jest-mock": "workspace:*",
"jest-util": "workspace:*"
},
"devDependencies": {
Expand Down
5 changes: 3 additions & 2 deletions packages/expect/src/__tests__/spyMatchers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import * as Immutable from 'immutable';
import {alignedAnsiStyleSerializer} from '@jest/test-utils';
import type {FunctionLike} from 'jest-mock';
import jestExpect from '../';

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

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

spy.calls = {
Expand All @@ -37,7 +38,7 @@ const createSpy = (fn: jest.Mock) => {
},
};

return spy;
return spy as unknown as jest.Mock<T>;
};

describe('toHaveBeenCalled', () => {
Expand Down
152 changes: 149 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 {MockInstance} 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,148 @@ export interface Matchers<R extends void | Promise<void>, T = unknown> {
*/
toThrow(expected?: unknown): R;
}

/**
* Obtains the parameters of the given function or {@link MockInstance}'s function type.
* ```ts
* type P = MockParameters<MockInstance<(foo: number) => void>>;
* // or without an explicit mock
* // type P = MockParameters<(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<MockInstance<(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<MockInstance<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 `MockInstance` type will evaluate to `Array<unknown>`:
* ```ts
* MockParameters<MockInstance> // Array<unknown>
* ```
*
* If the given type is not a `MockInstance` nor a function, this type will evaluate to `Array<unknown>`:
* ```ts
* MockParameters<boolean> // Array<unknown>
* ```
*/
type MockParameters<M> =
M extends MockInstance<infer F>
? FunctionParameters<F>
: FunctionParameters<M>;

/**
* A wrapper over `FunctionParametersInternal` which converts `never` evaluations to `Array<unknown>`.
*
* This is only necessary for Typescript versions prior to 5.3.
*
* In those versions, a function without parameters (`() => any`) is interpreted the same as an overloaded function,
* causing `FunctionParametersInternal` to evaluate it to `[] | Array<unknown>`, which is incorrect.
*
* The workaround is to "catch" this edge-case in `WithAsymmetricMatchers` and interpret it as `never`.
* However, this also affects {@link UnknownFunction} (the default generic type of `MockInstance`):
* ```ts
* FunctionParametersInternal<() => any> // [] | never --> [] --> correct
* FunctionParametersInternal<UnknownFunction> // never --> incorrect
* ```
* An empty array is the expected type for a function without parameters,
* so all that's left is converting `never` to `Array<unknown>` for the case of `UnknownFunction`,
* as it needs to accept _any_ combination of parameters.
*/
type FunctionParameters<F> =
FunctionParametersInternal<F> extends never
? Array<unknown>
: FunctionParametersInternal<F>;

/**
* 1. If the function is overloaded or has no parameters -> overloaded form (union of tuples).
* 2. If the function has parameters -> simple form.
* 3. else -> `never`.
*/
type FunctionParametersInternal<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;

/**
* @see FunctionParameters
*/
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 02ea166

Please sign in to comment.