As for frontend testing, Jest is great, no question about it. But to make its apis backward compatible, Jest has to compromise on type constraints when it comes to assert functions in TypeScript.
const onChangeMock: (field: string, value: number) => void = jest.fn();
onChangeMock('abc', 1);
expect(onChangeMock).toHaveBeenCalledWith('abc');
This test fails with following message
Expected mock function to have been last called with: undefined as argument 2, but it was called with 1
So why doesn't TypeScript complain about it at compile time?
Let's take a look at how toHaveBeenCalledWith
is declared:
toHaveBeenCalledWith(...params: any[]): R
Obviously, Jest doesn't add any type constraints to the parameters which can be any and any number of types.
Now that Jest doesn't type function assertion, let's add for it.
type WithNot<T> = T & { not: Omit<T, 'not'> };
type FunctionResultPick<T extends (...args: any[]) => any, P extends keyof jest.JestMatchers<T>> = {
[K in P]: K extends 'toHaveBeenCalledWith' | 'toHaveBeenLastCalledWith' ?
(...args: Parameters<T>) => T :
K extends 'toHaveBeeanNthCalledWith' ?
(nth: number, ...args: Parameters<T>) => T :
K extends 'toHaveReturnedWith' | 'toHaveLastReturnedWith' ?
(r: ReturnType<T>) => T :
K extends 'toHaveNthReturnedWith' ?
(nth: number, r: ReturnType<T>) => T :
jest.JestMatchers<T>[K]
};
type FunctionResult<T extends (...arg: any[]) => any> = WithNot<FunctionResultPick<T,
| 'toHaveBeenCalled'
| 'toHaveBeenCalledTimes'
| 'toHaveBeenCalledWith'
| 'toHaveBeenLastCalledWith'
| 'toHaveBeenNthCalledWith'
| 'toHaveReturned'
| 'toHaveReturnedTimes'
| 'toHaveReturnedWith'
| 'toHaveNthReturnedWith'
| 'toHaveLastReturnedWith'
| 'toThrow'
| 'toThrowError'
| 'toThrowErrorMatchingSnapshot'
| 'toThrowErrorMatchingInlineSnapshot'
>>;
type ResultPick<T, P extends keyof jest.JestMatchers<T>> = {
[K in P]:
K extends 'toBe' | 'toEqual' ?
([T] extends [undefined] | [null] ? (actual: any) => T : (actual: T) => T) :
K extends 'toContain' | 'toContainEqual' ? (T extends Array<infer A> ? (actual: A) => T : (actual: T) => T) :
jest.JestMatchers<T>[K]
};
type NonFunctionKeys<T> = Exclude<keyof jest.JestMatchers<T>, keyof FunctionResult<any>>;
type Result<T> = WithNot<ResultPick<T, NonFunctionKeys<T>>>;
export function expectThat<T>(actual: T): [T] extends [undefined] ? Result<T> :
[T] extends [(...args: any[]) => any] ? FunctionResult<T> : Result<T> {
return expect(actual) as any;
}
Now TypeScript complains if using expectThat
:
expectThat(onChangeMock).toHaveBeenCalledWith('abc');
TS2554: Expected 2 arguments but got 1.
It also complains if passing the wrong type:
expectThat(onChangeMock).toHaveBeenCalledWith('abc', 'de');
TS2345: Argument of type 'de' is not assignable to parameter of type 'number'.
Similarly, all following generate compiling errors
expectThat('123').toBe(123); // Argument of type 123 is not assignable to parameter of type 'string'.
expectThat(['123']).toContain(123); // Argument of type 123 is not assignable to parameter of type 'string'.
expectThat({a: '123'}).toEqual({a: 123}); // Argument of type 123 is not assignable to parameter of type 'string'.
expectThat('123').toHaveBeenCalled(); // toHaveBeenCalled does not exist.
expectThat(() => '').toEqual(''); // toEqual does not exist.
...
Happy hacking :)