jest icon indicating copy to clipboard operation
jest copied to clipboard

Parameterised mock return values

Open mattphillips opened this issue 7 years ago • 29 comments

🚀 Feature Proposal

Add .when/.thenReturn support to the Jest mock API.

when: Takes arguments to match the mock call against. thenReturn: Takes a vale to return when the when clause matches a given call.

Motivation

This behaviour exists in mocking libraries from other languages see Mockito

This API will allow more expressive mocks, extending on top of the idea of mockReturnValue and mockReturnValueOnce.

Example

test('Mock when(x).thenReturn(y)', () => {
  const mock = jest.fn()
    .when(1) // one primitive argument
    .thenReturn(2)
    .when(3, 4) // multiple primitive arguments 
    .thenReturn(7)
    .when('id')
    .thenReturn(a => a) // return a function
    .when({ hello: 'world' }) // object argument
    .thenReturn('Hello, world!');

  expect(mock(1)).toBe(2);
  expect(mock(3, 4)).toBe(7);
  expect(mock('id')('Awesome Jest')).toBe('Awesome Jest');
  expect(mock({ hello: 'world' })).toBe('Hello, world!');
});

The API in Mockito also offers argument matchers for given types and custom equality checkers i.e.

test('returns jest-cool when given a number)', () => {
  const mock = jest.fn()
    .when(any(Number))
    .thenReturn('jest-cool');
 
  expect(mock(1)).toBe('jest-cool');
  expect(mock(2)).toBe('jest-cool');
  expect(mock(3)).toBe('jest-cool');
});

test('returns 🍌 when given odd numbers)', () => {
  const isOdd = n => n % 2 != 0;
  const mock = jest.fn()
    .when(isOdd)
    .thenReturn('🍌');
 
  expect(mock(1)).toBe('🍌');
  expect(mock(3)).toBe('🍌');
  expect(mock(5)).toBe('🍌');
});

Pitch

Why in Core?

This could go in user land but the API would suffer for it! It would be more verbose and would not be able to bind to the mock. Instead it would have to mutate Jest's mock API which would likely discourage people from using it.

My Questions

What should happen if a mock call value doesn't match any when clauses?

  • Should the test fail?
  • Return undefined?
  • Return the original mock implementation?

cc/ @SimenB @rickhanlonii @cpojer

I'm happy to start on a PR for this if you guys are happy adding it to core :smile:

mattphillips avatar May 13 '18 19:05 mattphillips

This would be incredibly awesome, and really empower mock functions in jest.

A couple of questions come to mind, more as discussion points than anything else.

  • I wonder if this needs a times argument?
  • Any elegant way to assert that all cases were hit?
  • How would we differentiate between a chained when and actual .when properties? Making it a factory comes to mind, but that's smelly
  • Should it have throwing in addition to returning?

Also, both sinon and testdouble has something like this. Would be great to look at their APIs for inspiration, and maybe look through their issue tracker for foot guns and missing features people have encountered

SimenB avatar May 13 '18 20:05 SimenB

I like it.

cpojer avatar May 13 '18 20:05 cpojer

@SimenB

I wonder if this needs a times argument?

Ooo I like it! I'm not sure how we would add it to the API without it being a bit janky.

In my head we have .when(...args) so we can't put it in the function signature, unless times was the first argument meaning we can't put any defaults in. Do you have any suggestions?


Any elegant way to assert that all cases were hit?

If we do know the times all of the when clauses should be called we could add a new matcher, how about something like:

expect(mock).toHaveBeenCalledAllTimes();

How would we differentiate between a chained when and actual .when properties? Making it a factory comes to mind, but that's smelly

I'm not sure what you mean here? The when will be on our jest.fn() object along with other properties like mock, getMockImplementation, etc


Should it have throwing in addition to returning?

Yup I love this :smile:

Perhaps to make it explicit there could be a third API of .thenThrow(fn), for example:

jest.fn()
  .when(x)
  .thenThrow(() => throw new Error('💥'))

mattphillips avatar May 14 '18 09:05 mattphillips

After chatting with @rickhanlonii and @SimenB at the Jest summit we came up with the following new parameterised APIs:

New parameterised API Existing API
returnWhen(value, ...args) mockReturnValue(value)
resolveWhen(value, ...args) mockFn.mockResolvedValue(value)
rejectWhen(value, ...args) mockFn.mockRejectedValue(value)
throwWhen(value, ...args) Needs creating

The new APIs would allow us to write a mock like so:

const mock = jest.fn()
  .returnWhen(3, 1, 2)
  .resolveWhen(3, 3)
  .rejectWhen(new Error('Async error'), -2)
  .throwWhen('Error', -1);

TODO:

  • [ ] Add new parameterised APIs with aliases to original function names suffixed with When i.e. mockReturnValueWhen (Feature)
  • [ ] Add support for mock matchers, @rickhanlonii has some really good ideas for this and to share the logic between asymmetric matchers. For example .returnWhen(3, match.any(Date)). (Feature)
  • [ ] Update existing APIs to remove mock prefix and Value suffix from the mock function names (Breaking change)

I'm going to start working on this tonight/tomorrow and will have a PR open soon.

mattphillips avatar May 27 '18 10:05 mattphillips

Hi @mattphillips, any update on this? :)

cedric25 avatar Jul 24 '18 09:07 cedric25

Hi! Any updates?

I would love to help making this happen. This is one of my only gripes with Jest.

It seems there's an implementation of this in these projects:

  • https://github.com/timkindberg/jest-when
  • https://github.com/jonasholtkamp/jest-when-xt (a fork of jest-when with more features)

Also, I'm not thrilled with the new API. It's less readable when the return value and the arguments are written in the same context next to each other.

fn.returnWhen(10, 5, 2);

Which one is the return value? Which ones are the arguments? It's not immediately obvious because of the lack of separation.

I think that Mockito's API is the cleanest:

when(mockFn(1, 2)).thenReturn('yay!');

But I assume this is too far from Jest's current API design (although I would love for Jest to follow suite), and requires changing how function calls are intercepted.

jest-when has this:

when(mockFn).calledWith(1, 2).mockReturnValue('yay!')
when(mockFn).expectCalledWith(1, 2).mockReturnValue('yay!') // with assertion that a call was made

Which I think is pretty clean.

If wrapping the mock is still too different than the rest of the API, then maybe something like this, borrowing from SinonJS:

mockFn.withArgs(1, 2).mockReturnValue('yay!')

Clean and Jesty :)

Thoughts?

myoffe avatar Sep 07 '18 19:09 myoffe

As another data point, Jasmine has withArgs: https://jasmine.github.io/api/edge/Spy.html#withArgs

SimenB avatar Oct 31 '18 23:10 SimenB

@mattphillips check this one out: https://www.npmjs.com/package/jest-when. Seems very similar to our proposal here? It looks super awesome, but I haven't tried it out

/cc @idan-at @timkindberg thoughts on the API and suggestions in this thread?

SimenB avatar Jan 16 '19 17:01 SimenB

@SimenB it looks like jest-when has come on quite a bit since I last checked it out last year!

The jest-when API appears to be closer to my original proposal of chaining when(x).then(y) or the withArgs API in Jasmine.

When we chatted at the Jest summit, we decided to drop the intermediary chain of when/withArgs and have a single API that includes both the args and value to be returned/resolved/rejected/thrown.

I think it's worth getting a consensus on which API we prefer.

I can see pros/cons for both, having one API for both feels more concise but is probably a little more confusing due to argument order mattering.

The when(x).then(y) style is certainly more explicit but what happens for trailing whens without closing thens and is obviously more verbose (possibly a moot point)?

Personally I prefer things to be more explicit.

I'm definitely keen to hear what @idan-at @timkindberg think on the API design?

mattphillips avatar Jan 16 '19 18:01 mattphillips

haha, it's actually linked in this issue 😛 never noticed that...

SimenB avatar Jan 16 '19 18:01 SimenB

So I don't much like Java, but I did feel I was missing out on Mockito's when().thenReturn() while testing JavaScript. So I created jest-when as a spiritual port of mockito's when capability.

I don't like the existing API suggestion of fn.returnWhen(10, 5, 2); for the same reasons @myoffe mentions.

I think maybe something like this might be best:

jest.mock().whenCalledWith(...args).mockReturnValue(someVal)

I would have done this in jest-when but didn't want to decorate the global jest.mock object.

This will stay consistent with terms that jest already uses, there's no need to introduce new words and phrases to the API (other than "when"):

  • whenCalledWith is balanced out by the calledWith assertion
  • mockReturnValue can stay the same. This empowers the existing mockReturnValue nomenclature because now it can be paramterised or not-parameterised. Another ease of development is this makes it easy to convert a standard mock into a "when" mock. Like so:
// Before (a regular mock)
jest.mock().mockReturnValue(true)

// After (you realize you'd like to parameterize it, just insert `whenCalledWith()`)
jest.mock().whenCalledWith('foo').mockReturnValue(true)

It's best to keep one method for defining arguments and one method for defining what to return. This way they can easily be swapped, like so:

// Before (a regular when mock)
jest.mock().whenCalledWith('foo').mockReturnValue(true)

// After (you realize you actually need a resolved promise returned, just swap for `mockResolvedValue`)
jest.mock().whenCalledWith('foo').mockResolvedValue(true)

I'm biased but I recommend going with the jest-when api with the only change being do not use a wrapper when function; instead convert the calledWith method name to whenCalledWith for clarity.

I see the desire to have nice short methods like returnWhen but the nice-short-method-names ship sailed the minute jest introduced mockReturnValue. So I think it's more important to keep consistent with the existing nomenclature.

timkindberg avatar Jan 17 '19 02:01 timkindberg

but what happens for trailing whens without closing thens

I would think it just returns undefined, since that's the default return value for any mock.

timkindberg avatar Jan 17 '19 02:01 timkindberg

Just wanted to add...

if you went with my proposal you'd only have to document one new method: whenCalledWith. You'd just have to describe how it can be inserted before any existing mock*Value method.

Also if you didn't notice, jest-when supports jest matchers as parameters which is also balanced with the assertion API. We could do that too like this:

fn.whenCalledWith(
  expect.anything(),
  expect.any(Number),
  expect.arrayContaining(false)
).mockReturnValue('yay!')

const result = fn('whatever', 100, [true, false])
expect(result).toEqual('yay!')

timkindberg avatar Jan 17 '19 02:01 timkindberg

  • I wonder if this needs a times argument?

We already have:

expect(mockFn).toHaveBeenCalledTimes(number)

https://jestjs.io/docs/en/expect#tohavebeencalledtimesnumber

timkindberg avatar Jan 17 '19 03:01 timkindberg

@SimenB to be honest, I did not take part of jest-when's design, I've only recently contributed to it, because I think it's awesome :)

I believe that the withArgslike API has the most pros, it's very explicit and won't confuse new comers. (and it can be called whenCalledWith, as @timkindberg suggested - its better than withArgs). The major pros here is minimal new API, very explicit, and support for mock once (using mockResolvedValueOnce).

The when(x).then(y) style is certainly more explicit but what happens for trailing whens without closing thens and is obviously more verbose (possibly a moot point)?

The builder pattern is well known for its stateful behaviour, and using that for answering the question, it will simply be ignored. 
Keep in mind we should address the case of multiple whens and a single then. I think this should not be supported, since async matchers can solve that.

So eventually the API will look like:

jest.fn()
  .whenCalledWith(expect.any(String)).mockReturnedValueOnce(1) // on first call with string, return 1
  .mockReturnedValue(42) // on any other call (second with string, first with anything which isn't a string, return 42)

idan-at avatar Jan 20 '19 08:01 idan-at

Another good reference for that type of behavior is testdoublejs. They have a very specific usecase for mocks and the usecase is from what I gather exactly what is being proposed here.

Might be worth checking out for API ideas.

venikx avatar Jan 27 '19 22:01 venikx

Looks like the current thinking around this API is quite different than what's currently shipped, so my suggestion in #7943 probably isn't very relevant, but with this new API it will be even more important to support thenCallThrough (or something to that effect), which will achieve the same goal.

davidje13 avatar Feb 21 '19 15:02 davidje13

Hello. Wondering on any updates for these new APIs?

lgc13 avatar Nov 08 '19 19:11 lgc13

<travolta meme here> Guys, anyone? Started using jest heavily and didn't find this functionality, but found this thread.

glebsts avatar Dec 16 '19 22:12 glebsts

Hi folks, this looks lovely. Some prior art to reference may be testdouble's api

tim-evans avatar Jan 31 '20 16:01 tim-evans

Bumping this thread, I feel this should be in the core

Sytten avatar Apr 04 '20 02:04 Sytten

Bumping this, it is a very useful feature.

waseem avatar Oct 27 '20 20:10 waseem

While I haven't though about this issue in a long time, I think I'm convinced by @timkindberg's argument in https://github.com/facebook/jest/issues/6180#issuecomment-455018418

if you went with my proposal you'd only have to document one new method: whenCalledWith. You'd just have to describe how it can be inserted before any existing mock*Value method.

This sounds very compelling to me. @timkindberg what do you think almost 2 years later?

/cc @cpojer @thymikee @jeysal @mattphillips @rickhanlonii thoughts on potentially just adopting whenCalledWith from jest-when?

SimenB avatar Oct 27 '20 20:10 SimenB

@SimenB Yes I still think its a good solution. jest-when is still an active library with decent usage. Just got some PRs this week in fact.

Only issue I see is maybe the way I've gone about writing jest-when is more monkey-patch or hack than actual implementation. I wonder how the code might change if it was able to be embedded within jest's actual source code. I have not spent much time looking at the jest codebase to know if the code from jest-when is transferrable or if it would be rewritten.

Also the API would change, which I think is clear in my comments above. But just to reiterate, in jest-when we do when(spyFn).calledWith(...).mockReturnValue(...) but the api in jest would be better as spyFn.whenCalledWith(...).mockReturnValue(...).

I'd be happy to give a core dev a tour of jest-when if they are interested.

timkindberg avatar Oct 27 '20 22:10 timkindberg

Cool, thanks @timkindberg! One thing you could do to re-create the API we'd have in Jest would be to extend from the default jest environment but override this.moduleMocker.

// and jsdom
const NodeEnv = require('jest-environment-node');

module.exports = class NodeEnvWhen extends NodeEnv {
  constructor(...args) {
    super(...args);

    this.moduleMocker = new JestWhenModuleMocker(this.global);
  }
};

jest.fn is essentially just environment.moduleMocker.fn.bind(environment.moduleMocker), see https://github.com/facebook/jest/blob/132e3d10068834e3f719651cdc99e31b7c149f3b/packages/jest-runtime/src/index.ts#L1526-L1527

(It'll be weird with legacy fake timers if you rely on them being mocked, but that's an edge case I think)

That way you wouldn't need the wrapper function. Might be a good way for people to play with the API suggested here? Not that wrapping in a when is super painful, of course.


This of course goes for all other approaches as well - good way to experiment with different APIs

SimenB avatar Oct 27 '20 23:10 SimenB

I definitely do not follow completely. I think you are saying this is a pattern that could be followed to "try the api out"?

Wouldn't someone just add the whenCalledWith functionality straight into the jest-mock package as a method on the fn?

I'm looking at this file: https://github.com/facebook/jest/blob/132e3d10068834e3f719651cdc99e31b7c149f3b/packages/jest-mock/src/index.ts#L662

And it's doing a lot of the same stuff we did in jest-when where it all eventually funnels to mockImplementation. Everything is just sugar on top. So that looks a bit familiar.

If I were to work on the PR I'd need a bit more direction I think.


Side Announcement: jest-when has recently been added to the Adopt portion of the Thoughtworks Tech Radar 🥳
https://www.thoughtworks.com/radar/languages-and-frameworks?blipid=201911030

timkindberg avatar Oct 28 '20 13:10 timkindberg

Anyone currently working on it? If not, I’d love to take a shot with a PR. Been using Mockito on Java for long and recently started using jest and I’m really missing this.

nirga avatar Jan 12 '21 22:01 nirga

@nirga over a year later - please do! 😀

SimenB avatar Feb 24 '22 16:02 SimenB

Is there currently not a way to return different mocked values depending on args 🤯, or is this a proposal for something more ergonomicc? What's the current solution, it seems like a basic necessity so there must be something?

Edit: seems like jest-when package is the solution

dominictobias avatar Apr 14 '22 12:04 dominictobias