Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce a more flexible and better typed way to mock APIs #7832

Open
fgrandel opened this issue Feb 8, 2019 · 19 comments
Open

Introduce a more flexible and better typed way to mock APIs #7832

fgrandel opened this issue Feb 8, 2019 · 19 comments

Comments

@fgrandel
Copy link

fgrandel commented Feb 8, 2019

馃殌 Feature Proposal

This introduces an easy-to-use, lightweight and concise way to (partially) mock typed APIs (Typescript modules, types, classes and above all interfaces) without introducing any breaking change to the API.

Motivation

Mocking interfaces on a per-test basis is not possible right now in jest. IMO it is good testing practices to NOT re-use mocks across tests as this quickly makes the mock become a hard-to-maintain object in its own right. Plus shared mocks introduce unwanted dependencies between tests.

It is rather established practice to generate light-weight throw-away mocks for each test case that only mock a minimal set of API methods to document what is actually being used by the SUT (and throwing errors if something unexpected is being used). This is currently not well supported by jest - neither for modules nor for hashes/classes and not at all for interfaces.

The mocking of interfaces is central to good programming practices, though, as APIs should always be implementations of interfaces and tests should mock the interface rather than a specific implementation as this will much better decouple the test from the underlying API and avoid false negatives when the implementation details change.

Example

// The interface to be mocked - no implementation must be known/available!
// It is even possible to do TDD with deferred implementation of the API, e.g.
// when the API is to be developed at a later time/in parallel by another team
// or team mate.
interface MyApi {
   someMethod(x: number): string;
   someOtherMethod(): void;
   someThirdMethod(): void;
}

test('...', () => {
  // Declare the mock - the correct Mocked type will be automatically
  // inferred when initialising the variable right away (const ... = mock<...>(...))
  // which makes the implementation even more concise.
  let myMockedApi: Mocked<MyApi>

  // Partially mocking the class or interface. The mock function takes the methods to be
  // mocked as arguments. The arguments are strongly typed - so TS will throw a compile-
  // time error if you try to mock non-existent methods.
  // The test documents which methods are expected to be used by the SUT for the given
  // test case. 
  myMockedApi = mock<MyApi>('someMethod', 'someOtherMethod')

  // TS-enabled IDEs will now provide completion for both, the mock functions as well
  // as the original function itself!
  myMockedApi.someMethod(...) // IDE will propose/check the "x" argument
  myMockedApi.someMethod.mockReturnValue('12345') // IDE will propose/check all mock methods

  // Matchers work normally:
  expect(myMockedApi.someMethod).toHaveBeenCalledWith(...)

  // If the SUT invokes a non-mocked (unexpected) method on the API an error will be thrown:
  myMockedApi.thirdMethod() // will throw
})

Pitch

Jest wants to provide a best-in-class typed mocking solution based on current best testing practice and comparable in capability to established typed mocking solutions for other languages. Jest has chosen TypeScript as one of its major language targets and therefore wants to provide best-in-class TypeScript/IDE support for its API. Currently a fundamental mocking feature is missing, though, which often means that users that want full typing of their mocks are forced to use 3rd party mocking solutions or create/maintain their own and cannot use jest's built-in mocking.

Working sample implementation of the above mocking API

Obs: This sample implementation currently requires @types/jest 24.x plus the changes proposed in DefinitelyTyped/DefinitelyTyped#32956 (PR) and DefinitelyTyped/DefinitelyTyped#32901 (Issue). It is however easily adaptable to work with mainline @types/jest or any (future) jest core typings. We chose to base our proposal on patched typings to show how we think this should be done properly (based on our current personal opinions and preferred choices).

type GenericFunction = (...args: any[]) => any

export type MockFunction<F extends GenericFunction> = F & jest.Mock<ReturnType<F>, ArgsType<F>>

export type Mockable<T> = {
  [K in keyof T]: GenericFunction
}

export type Mocked<T extends Mockable<T>> = {
  [K in keyof T]: MockFunction<T[K]>
}

type PropOf<T> = T[keyof T]

export function mock<T extends Mockable<T>>(...mockedMethods: (keyof T)[]): Mocked<T> {
  const mocked: Mocked<T> = {} as Mocked<T>
  mockedMethods.forEach(mockedMethod => mocked[mockedMethod] = jest.fn<PropOf<T>>() as MockFunction<PropOf<T>>)
  return mocked
}
@SimenB
Copy link
Member

SimenB commented Feb 8, 2019

Thanks for the detailed proposal!

This is related to #4257 - whatever API we come up with should work with both Flow and TS. I don't know enough (about either type system) to really contribute a lot to this conversation, but on the surface something like what you propose sounds awesome.

/cc @orta @aaronabramov @cpojer

@fgrandel
Copy link
Author

fgrandel commented Feb 8, 2019

@SimenB: I agree! :-) Contrary to #4257 the intention of this feature proposal is not to propose a specific jest mocking API but some practical ideas how to implement strongly typed interface mocking in TypeScript. Very much looking forward to the API you'll come up with in the future.

Once you have decided upon a target API I will most probably be able to adapt this feature request accordingly while maintaining its basic capability. Just let me know.

Thanks for your great testing framework btw. It's a joy to work with. :-)

@fgrandel
Copy link
Author

fgrandel commented Feb 11, 2019

Here is a more advanced version that allows to mock types with non-function props:

type GenericFunction = (...args: any[]) => any

type PickByTypeKeyFilter<T, C> = {
  [K in keyof T]: T[K] extends C ? K : never
}

type KeysByType<T, C> = PickByTypeKeyFilter<T, C>[keyof T]

type ValuesByType<T, C> = {
  [K in keyof T]: T[K] extends C ? T[K] : never
}

type PickByType<T, C> = Pick<ValuesByType<T, C>, KeysByType<T, C>>

type MethodsOf<T> = KeysByType<Required<T>, GenericFunction>

type InterfaceOf<T> = PickByType<T, GenericFunction>

type PartiallyMockedInterfaceOf<T> = {
  [K in MethodsOf<T>]?: jest.Mock<InterfaceOf<T>[K]>
}

export function mock<T>(...mockedMethods: MethodsOf<T>[]): jest.Mocked<T> {
  const partiallyMocked: PartiallyMockedInterfaceOf<T> = {}
  mockedMethods.forEach(mockedMethod =>
    partiallyMocked[mockedMethod] = jest.fn())
  return partiallyMocked as jest.Mocked<T>
}

@michaelw85
Copy link
Contributor

Is there any indication to when an improvement can be expected? I'm really struggling trying to get my typings correct with a strict tslint setup. I've asked help on discord and create an issue on stackoverflow but I'm hitting a wall.

Looking at this issue this improvement is exactly what I'm looking for!

@abierbaum
Copy link

@Jerico-Dev This is excellent work. I was just looking for how to do this myself. Thanks for sharing this with the community. It would be great to see this pulled into mainline of Jest typing.

@mrdulin
Copy link

mrdulin commented Sep 3, 2019

Thanks. It works. This is what I am looking for.

My issue is: I don't want to mock all method for a class, because those methods are not ready to be tested. But I need let the type validation of typescript pass firstly without modifying the interfaces for implementation.

For example, if I don't use the partial mock function, tsc will throw a type error for adSubscriptionDataSource like this:

is missing the following properties from type 'IAdSubscriptionDataSource': updateById, findByActive, relation, find, and 6 more

some methods of adSubscriptionDataSource are not ready to be tested, so I don't want to mock them at this time. I just need the type validation of typescript pass. Use mock helper function can do this and take care of the partial mocked type issue.

鍥剧墖

This typed way should be documented.

@vschoener
Copy link

Hey guys,

After reading all of this and a few other articles online, since jest upgrade I'm no longer able to mock a class partially targetting specific method.

Do you guys have a proper way to mock a class? Create an instance right after and injecting it?

Thank you :)

@marchaos
Copy link

marchaos commented Nov 18, 2019

I ended up writing a library to do this - https://github.com/marchaos/jest-mock-extended, which follows @Jerico-Dev's initial proposal pretty closely, but adds some extra stuff like calledWith which was another use case that we are using.

@github-actions
Copy link

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

@github-actions github-actions bot added the Stale label Feb 25, 2022
@eyalroth
Copy link

This issue is not stale, at least not for me.

If it wasn't for @marchaos library (thanks a lot!), I'd consider dropping jest in favor of other testing/mocking frameworks like mocha. This is an essential feature, and a much better approach to writing tests in general IMHO.

@github-actions github-actions bot removed the Stale label Feb 27, 2022
@SimenB
Copy link
Member

SimenB commented Feb 27, 2022

jest-mock has seen extensive improvements to its types in Jest v28 via #12435, #12442 and #12489

import {jest} from '@jest/globals';

interface MyApi {
  someMethod(x: number): string;
  someOtherMethod(): void;
  someThirdMethod(): void;
}

const mockedApi = jest.mocked({} as MyApi);

image

image

So I think this issue as stated in the OP is solved? Note that this will require you to have a mock object. Main difference is we need to get passed an object with mocks. We could probably add a Proxy implementation like jest-mock-extended at least the type part of this feature request seems resolved? I'd be happy to take a PR adding e.g. jest.generateMock or something which works like mock from jest-mock-extended. I think something like

import * as someModule from 'some-module';
import {jest} from '@jest/globals';

jest.mock('some-module');


const mockedModule = jest.mocked(someModule);

Is better than some proxy as your setting up mocks that will be used by others. Could you show real use cases where the proxy approach is better/cleaner?


The other feature jest-mock-extended provides beyond better types (as far as I can see, apologies if I missed others) is parameterized mocks, which we track in #6180

@eyalroth
Copy link

eyalroth commented Feb 27, 2022

import * as someModule from 'some-module';
import {jest} from '@jest/globals';

jest.mock('some-module');

const mockedModule = jest.mocked(someModule);

Is better than some proxy as your setting up mocks that will be used by others. Could you show real use cases where the proxy approach is better/cleaner?

@SimenB I'm not too familiar with the differences between jest-mock-extended's proxy approach and Jest's MaybeMock, so I might be confusing some topics here.

My main gripe with Jest's mocking is the reliance on global/static mocking (of modules). I highly prefer building my code with classes that their dependencies are injected via their constructor; in testing, that means injecting local mock instances instead of mocking modules globally in place.

I completely understand the reasoning behind Jest's approach given that many JS codebases are still mainly procedural and don't use the dependency-injection approach of OOP. I hope Jest could also provide the alternative for OOP ehnthusiastics :)

@SimenB
Copy link
Member

SimenB commented Feb 27, 2022

You can also do jest.createMockFromModule('some-module') (this is what jest.mock does under the hood if you don't provide a mock factory). But if you don't want to mock at the module boundary at all, then I guess it's not as useful.

One limitation of the approach from the OP (and I guess jest-mock-extended) is that it only works for functions, not other types. Which might be fine, depending on what you're doing.


That said, I do think something like

import {jest} from '@jest/globals';

interface MyApi {
  someMethod(x: number): string;
  someOtherMethod(): void;
  someThirdMethod(): void;
}

const myApi: MyApi = {
  someMethod: jest.fn<(x: number) => string>(),
  someOtherMethod: jest.fn(),
  someThirdMethod: jest.fn(),
};

const mockedApi = jest.mocked(myApi);

is fine. The Proxy approach doesn't seem like it adds too much? IMO closer to the "local mock instances" you say you prefer.

Slightly less typing

import {jest} from '@jest/globals';

interface MyApi {
  someMethod(x: number): string;
  someOtherMethod(): void;
  someThirdMethod(): void;
}

const mockedApi = jest.mocked({
  someMethod: jest.fn(),
  someOtherMethod: jest.fn(),
  someThirdMethod: jest.fn(),
} as MyApi);

If you have a "real" implementation you want to create a mock from, we could probably expose what createMockFromModule does to the loaded module (https://github.com/facebook/jest/blob/c2872aae7e3bdc8f9c236637ade294790a47d7f6/packages/jest-runtime/src/index.ts#L1759-L1771), essentially just moduleMocker.generateFromMetadata(moduleMocker.getMetadata(object)). jest.createMockFromObject?

@eyalroth
Copy link

One limitation of the approach from the OP (and I guess jest-mock-extended) is that it only works for functions, not other types. Which might be fine, depending on what you're doing.

Other types such as? you mean plain properties?

const mockedApi = jest.mocked({
 someMethod: jest.fn(),
 someOtherMethod: jest.fn(),
 someThirdMethod: jest.fn(),
} as MyApi);

I would love to not need to explicitly set each function as mock, i.e:

const mockedApi = jest.mocked<MyApi>();

or something along these lines.

This is similar to the approach taken by Sinon's createStubInstance and Java's mocking libraries such as Mockito.

@SimenB
Copy link
Member

SimenB commented Feb 27, 2022

If you by "plain properties" mean primitives, then yes. And classes or arrays. We also detect generator functions or async functions and return the correct type of function.

Both sinon and mockito seems to require passing an argument, which is what I'm suggesting? While I've used both in the past, it's been more than 6 years since either, so I've forgotten what I once knew about them 馃檲

@mrazauskas
Copy link
Contributor

Just a quick note. At the moment I am reworking jest.Mocked<T> utility type. It will do the same thing as jest.mocked<T>(arg: T) function currently does. Simply instead of a value, the utility type will take generic type argument. I think it will work like this:

import myApi from './myApi';

jest.mock('./myApi');

const mockedApi = myApi as jest.Mocked<typeof myApi>;

@eyalroth
Copy link

eyalroth commented Mar 3, 2022

@SimenB

If you by "plain properties" mean primitives, then yes. And classes or arrays. We also detect generator functions or async functions and return the correct type of function.

I think it's sufficient to only mock functions (including getters/setters).

Both sinon and mockito seems to require passing an argument, which is what I'm suggesting? While I've used both in the past, it's been more than 6 years since either, so I've forgotten what I once knew about them 馃檲

When stubbing a specific function? yes, but not when initializing the mock/stub.

@Thore1954
Copy link

This was already possible in jasmine for ages with createSpyObj<MyApi>. It's weird jest doesn't include something similar since it's based on jasmine.

@Suall1969
Copy link

If you have a "real" implementation you want to create a mock from, we could probably expose what createMockFromModule does to the loaded module (https://github.com/facebook/jest/blob/c2872aae7e3bdc8f9c236637ade294790a47d7f6/packages/jest-runtime/src/index.ts#L1759-L1771), essentially just moduleMocker.generateFromMetadata(moduleMocker.getMetadata(object)). jest.createMockFromObject?

Any update on this @SimenB ? Our teams are forcibly relying on external mocking libraries such as Sinon.js, moq.ts, td.js just for this feature. It is regrettable since jest already has all the foundation for creating mocks plus custom matchers.

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

No branches or pull requests