[Bug]: a mocked function `jest.fn<typeof fn>()` is not taking the original generics of the fn into account
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
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?
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.
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.
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.
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);
});
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>;
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"]);
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?
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.
Is there still an issue here with the updated docs?
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.
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.
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.
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.