timkindberg / jest-when

Jest support for mock argument-matched return values.
MIT License
738 stars 39 forks source link

Feature request: allow partial argument matches #66

Closed mcous closed 3 years ago

mcous commented 3 years ago

overview

I'm a big fan of jest-when, and it makes using jest mocks far more pleasant than using them vanilla. I have a feature request (that I'd be very open to PR'ing, if you find it acceptable) to cover a particular usage that we've run into at my company.

Currently, calledWith requires all function arguments to be specified. For the vast majority of use cases, I think this is sensible and correct. I think there are, however, a small set of times when it would be nice to be able to omit arguments in the stub configuration, because they truly do not matter for the subject under test.

Would you be open to adding an option to allow extra arguments to be ignored?

const myDouble = jest.fn()

// potential APIs
// new method
when(myDouble).partiallyCalledWith("foo").mockReturnValue("bar")
// options object in when
when(myDouble, { ignoreExtraArgs: true }).calledWith("foo").mockReturnValue("bar")

assert(myDouble("foo") === "bar")
assert(myDouble("foo", "other", "stuff") === "bar")

risks and alternatives

prior art

testdouble.js, which has a very similar API to jest-when and seems to me to come from the same stubbing lineage, has an ignoreExtraArgs option during stub configuration.

sinon.js does partial stubbing by default in its calledWith assertion. I think this is a bad default, but folks coming from Sinon would at least be familiar with this behavior.

(Edit) after a deeper issue search that I should've completed before hitting submit, I ran into #36. I think this request is related, but distinct, because I'm talking specifically about rare cases where trailing arguments have no meaning to the subject nor stub.

actual use case

In this particular case, we're mocking out React function components. We have a collaborator component, and we want to verify that some child component is properly arranged with the correct props:

import * as React from 'react'
import { when } from 'jest-when'
import { render, screen } from '@testing-library/react'
import { SomeChild } from './SomeChild'  // export const SomeChild = (props) => (<>{...}</>)
import { SomeParent } from './SomeParent'

jest.mock('./SomeChild')

describe('SomeParent component', () => {
  it('should pass xyz prop down to SomeChild', () => {
    when(ShomeChild).calledWith({ xyz: '123' }).mockReturnValue(<span>hello world</span>)

    render(<SomeParent xyz="123" />)
    expect(screen.getByText('hello world'))
  })
})

You (or rather, I) would expect this to work! However, it does not. For legacy reasons that have nothing to do with the code under test, the React rendering system passes two arguments the the SomeChild function, props, and something else called refOrContext. All of our function components are written to accept a single function parameter, props, so the fact that React is passing in a second parameter doesn't do anything because it's always ignored.

We can fix the test by doing...

when(ShomeChild)
  .calledWith({ xyz: '123' }, expect.anything())
  .mockReturnValue(<span>hello world</span>)

...but it's a little more verbose and, more importantly to me, muddies how the test communicates its intent to the reader

timkindberg commented 3 years ago

That's an interesting use case there, mocking a React component like that. Pretty neat.

I'm pretty torn on this, all of the options are inelegant.

const _ = expect.anything()
calledWith({ xyz: '123' }, _)
calledWith({ xyz: '123' }, expect.anything())
partialCalledWith({ xyz: '123' }, expect.anything())
when(fn, { ignoreExtraArgs: true }).calledWith({ xyz: '123' }, expect.anything())

// Some more ideas
calledWith({ xyz: '123' }).ignoreExtraArgs()
calledWith(when.all(somePredicateFnThatReceivesAllArgsAndReturnsTrueOrFalse)) // this would build on the recent addition of Function matchers
mcous commented 3 years ago

All credit to @shlokamin on trying out that React component mock. This feature request comes from the both of us being very confused as to why when(FunctionComponent).calledWith(props) wasn't working, only to discover React had been passing a second parameter in the whole time. Fun day!

all of the options are inelegant.

Yeah, there's a very pleasant terseness to the existing when API. Throwing an options object in when feels weird, and adding an entire new method like parialCalledWith feels like a lot. I sorta gravitate towards calledWith({ xyz: '123' }).ignoreExtraArgs() or something similar (e.g. withConfig), but still...

Leaning on the function matchers seems pretty interesting, too, and might make userland solutions (or at least experimentation) easier.

timkindberg commented 3 years ago

https://github.com/timkindberg/jest-when/releases/tag/v3.3.0