Skip to content

Instantly share code, notes, and snippets.

@bozhanglab49
Last active January 18, 2021 03:30
Show Gist options
  • Save bozhanglab49/fe451950c945f4d02f50d1df7911b1fc to your computer and use it in GitHub Desktop.
Save bozhanglab49/fe451950c945f4d02f50d1df7911b1fc to your computer and use it in GitHub Desktop.
TWL-Lab49/Learning TypeScript - Jest is great! However...

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 :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment