jest icon indicating copy to clipboard operation
jest copied to clipboard

[Bug]: a mocked function `jest.fn<typeof fn>()` is not taking the original generics of the fn into account

Open JelleRoets-Work opened this issue 2 years ago • 10 comments

Version

29.3.0

Steps to reproduce

If you have a function with a generic type and you mock that function, the generics of the original function are not taken into account on the mocked function.

const fn = <T>(a: T) => a;
const res = fn<string>('test'); // typeof res = string

const mockedFn = jest.fn<typeof fn>(fn)
const mockRes = mockedFn<string>('test'); // typeof mockRes = unknown and error when trying to set the generic: "Expected 0 type arguments, but got 1";

As a consequence the return type of mocked function cannot be correctly inferred (and will be unknown) nor can be explicitly set as a generic.

Expected behavior

The expected behaviour is that the type of the mocked function exactly equals the type of the original function, including the generics (but extended with the mock instance interface). That should allow us to do

const fn = <T>(a: T) => a;
const mockedFn = jest.fn<typeof fn>(fn)
const mockRes = mockedFn<string>('test'); // typeof mockRes = string

Actual behavior

like described in the steps to reproduce

Potential Solutions

update the type definition of the Mock https://github.com/facebook/jest/blob/61a64b53fe72b00fb17d7aabe5a54c4d415a845f/packages/jest-mock/src/index.ts#L135-L140 into

export declare type Mock<T extends FunctionLike = UnknownFunction> = T & MockInstance<T>;

or

export declare interface Mock<T extends FunctionLike = UnknownFunction> extends T, MockInstance<T> {}

although the latter also gives a type error: Interface 'Mock<T>' incorrectly extends interface 'T'. 'Mock<T>' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'FunctionLike'

Looking for some expert knowledge on the consequences of this potential change?

Environment

System:
    OS: macOS 13.0.1
    CPU: (10) arm64 Apple M1 Pro
  Binaries:
    Node: 16.17.1 - ~/.nvm/versions/node/v16.17.1/bin/node
    Yarn: 1.22.19 - ~/Github/platform/node_modules/.bin/yarn
    npm: 8.15.0 - ~/.nvm/versions/node/v16.17.1/bin/npm

JelleRoets-Work avatar Jan 13 '23 00:01 JelleRoets-Work

Could you tell more about the usage example?

I mean, it isn’t clear why do you need to call the mock (this line: const mockRes = mockedFn<string>('test')). Do you call it in a test file? This looks unusual for my eye. Usually mocks are created in a test file, called by the code under test and assertions are made using mock property.

Do you call a mock with values of different type in a test file? Hm.. What do I miss?

mrazauskas avatar Jan 13 '23 08:01 mrazauskas

Below I've started from an example from your documentation https://jestjs.io/docs/mock-function-api#jestfnimplementation and modified it a bit to show the same issue as above:

const add = <T>(a: T, b: T): T => (a as any) + b; // don't mind the `as any`

const sum = add<number>(1, 2); // typeof sum = number
const concatenation = add<string>('1', '2'); // typeof concatenation = string

test('calculate calls add', () => {
  // Create a new mock that can be used in place of `add`.
  const mockAdd = jest.fn<typeof add>();

  // `.mockImplementation()` now CANNOT infer the types of a, b as there is no way to pass the generic T
  mockAdd.mockImplementation((a, b) => {
    // Yes, this mock is still adding two numbers but imagine this
    // was a complex function we are mocking.
    return (a as any) + b;
  });

  // `mockAdd` is NOT properly typed and therefore accepted by anything requiring `add`.
  const sum = mockAdd<number>(1, 2); // typeof sum = unknown + error on the usage of the generic

  expect(sum).toEqual(3);
  expect(mockAdd).toHaveBeenCalledTimes(1);
  expect(mockAdd).toHaveBeenCalledWith(1, 2);
});

Small note: for the above add function (from your original documentation), the usage of generic types isn't entirely correct (hence the as any in the sum). But that doesn't really matter to show the issue, this is just a showcase that stays close to an existing example. You can easily replace the add function with any other function for which it does make sense / is required to use generics.

JelleRoets-Work avatar Jan 13 '23 10:01 JelleRoets-Work

Do you have a real world case?

This looks hypothetical and I think you misunderstood example from the docs:

// 1. Create a mock
const mockAdd = jest.fn<typeof add>();

// 2. Pass the mock as a callback
calculate(mockAdd, 1, 2);

// 3. Assert the mock was called
expect(mockAdd).toHaveBeenCalledTimes(1);

In the above, the calculate() function is under test, not the add(). I try to understand what you are trying to tests:

// 1. Create a mock
const mockAdd = jest.fn<typeof add>();

// 2. Call the mock
const sum = mockAdd<number>(1, 2); // typeof sum = unknown + error on the usage of the generic

// 3. Assert the mock is called
expect(mockAdd).toHaveBeenCalledTimes(1);

Here add() is under test, but why? Hm.. We just called the mock, why to assert it was called? Also the implementation was mocked, why to test it? That is obvious. Nothing to test here.

Why do you need to create a mock at all? This is what is puzzling me.

mrazauskas avatar Jan 13 '23 11:01 mrazauskas

Ok my real world usecase is kinda similar to this example: https://jestjs.io/docs/mock-functions#mocking-modules

We have an API to fetch data form our service, which can return documents of various types, so we define that as a generic T. Now I want to create a test, that will call this function to fetch the data, does a couple of things with it and test the results. For this test, I want to mock the real api call with a mockImplementation that returns a fixed result. But I cannot use the mocked function with a generic as if I would call the original function, which results in ts errors.

import axios from 'axios';

type Admin = { name: string };
type User = { name: string; permissions: string[] };
const fetch = <T>(type: string): Promise<T> => axios.get<T>(`/user/${type}`).then(resp => resp.data);

test('handling users', async () => {
  // Create a new mock that can be used in place of the real api call.
  const mockFetch = jest.fn<typeof fetch>();

  mockFetch.mockImplementation(async <T>(type: string) => {
    if (type === 'admin') return { name: 'admin' } as T;
    else if (type === 'user') return { name: 'user', permissions: ['read'] } as T;
    else throw new Error('Unknown type');
  });

  const userProfile = await mockFetch<User>('user'); // typeof userProfile = unknown

  expect(userProfile.permissions).toEqual(['read']); // ts error: 'userProfile' is of type 'unknown'.
  expect(mockFetch).toHaveBeenCalledTimes(1);
});

This is of course again a simplified example! So If you want to make it even more realistic: the fetch function is implemented in a separate module, in my test file I mock this function very similar to https://jestjs.io/docs/mock-functions#mocking-partials, then write a test that uses this (mocked) function as if I would use the original function. But that's all boilerplate, to just show there is a typing issue on the return result of jest.fn.

JelleRoets-Work avatar Jan 13 '23 12:01 JelleRoets-Work

Thanks. That looks more clear. Why do you call mockFetch instead of fetch?

import { expect, jest, test } from "@jest/globals";
import { fetch } from "./fetch";

type User = { name: string; permissions: string[] };

const mockFetch = jest
  .fn<typeof fetch>()
  .mockImplementation(async <T>(type: string) => {
    if (type === "admin") return { name: "admin" } as T;
    else if (type === "user")
      return { name: "user", permissions: ["read"] } as T;
    else throw new Error("Unknown type");
  });

jest.mock("./fetch", () => {
  return {
    fetch: mockFetch,
  };
});

test("handling users", async () => {
  //
  // Why not just to call `fetch` here? It is imported from `"./fetch"` and is mocked.
  //
  const userProfile = await fetch<User>("user");

  expect(userProfile.permissions).toEqual(["read"]);
  expect(mockFetch).toHaveBeenCalledTimes(1);
});

mrazauskas avatar Jan 13 '23 12:01 mrazauskas

now you have to have access to both the original and mocked function, the original function to call (where you can use the generics), which will under the hood be redirected to the mocked function. But in case you want to assert on havebeenCalled, or change the mockImplementation or ReturnValue, you'll need the mocked function instead as those interfaces are not available on the original function interface.

So it would be even more convenient to just combine both and return that as the result of the jest.fn hence my suggestion:

export declare type Mock<T extends FunctionLike = UnknownFunction> = T & MockInstance<T>; 
fn<T extends FunctionLike = UnknownFunction>(implementation?: T): Mock<T>;

JelleRoets-Work avatar Jan 13 '23 13:01 JelleRoets-Work

Maybe this is what you are looking for:

import { expect, jest } from "@jest/globals";
import { fetch } from "./fetch";

type User = { name: string; permissions: string[] };

// `fetch` gets mocked somehow

jest.mocked(fetch).mockImplementation(async <T>() => ({} as T));

jest.mocked(fetch).mockResolvedValue("something");

jest.mocked(fetch).mock.calls[0][0]

// and at the same time:

const userProfile = await fetch<User>("user");
expect(userProfile.permissions).toEqual(["read"]);

mrazauskas avatar Jan 13 '23 15:01 mrazauskas

Ok thanks that is indeed a working (workaround)

Please note that some of your typescript examples in the documentation is not following the above pattern and hence resulting in similar ts errors:

2nd example of https://jestjs.io/docs/mock-function-api#mockfnmockimplementationfn, SomeClass.mockImplementation will produce a typescript error Property 'mockImplementation' does not exist on type 'typeof SomeClass'. see https://codesandbox.io/s/someclass-example-qc6i0i?file=/SomeClass.test.ts

According to your above solution this should change into jest.mocked(SomeClass).mockImplementation, correct?

JelleRoets-Work avatar Jan 16 '23 13:01 JelleRoets-Work

Good catch. That is a mistake. I just opened #13775 to fix it.


Note that TypeScript does not know that you are importing an API which was mocked. jest.mocked() is a simple helper to make the compiler aware of that. You could also write it this way:

import { jest } from "@jest/globals";
import { fetch } from "./fetch";

(fetch as jest.Mocked<typeof fetch>).mockImplementation(
    async <T>() => ({} as T)
  );

(fetch as jest.Mocked<typeof fetch>).mockResolvedValue("something");

(fetch as jest.Mocked<typeof fetch>).mock.calls[0][0];

At first I was in favour of a type cast, but must admit that jest.mocked() is easier to read.

mrazauskas avatar Jan 16 '23 19:01 mrazauskas

Is there still an issue here with the updated docs?

SimenB avatar Jan 17 '23 13:01 SimenB

This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 30 days.

github-actions[bot] avatar Feb 16 '23 13:02 github-actions[bot]

This issue was closed because it has been stalled for 30 days with no activity. Please open a new issue if the issue is still relevant, linking to this one.

github-actions[bot] avatar Mar 18 '23 13:03 github-actions[bot]

This issue was closed because it has been stalled for 30 days with no activity. Please open a new issue if the issue is still relevant, linking to this one.

github-actions[bot] avatar Mar 18 '23 13:03 github-actions[bot]

This issue has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs. Please note this issue tracker is not a help forum. We recommend using StackOverflow or our discord channel for questions.

github-actions[bot] avatar Apr 18 '23 00:04 github-actions[bot]