Loose match one value in jest.toHaveBeenCalledWith

I have an analytics tracker that will only call after 1 second and with an object where the intervalInMilliseconds (duration) value is not deterministic.

How can I use jest.toHaveBeenCalledWith to test the object?

 test('pageStats - publicationPage (will wait 1000ms)', done => {
  const track = jest.fn()

  const expected = new PayloadTiming({
    category: 'PublicationPage',
    action: 'PublicationPage',
    name: 'n/a',
    label: '7',
    intervalInMilliseconds: 1000 // or around
  })

  mockInstance.viewState.layoutMode = PSPDFKit.LayoutMode.SINGLE
  const sendPageStats = pageStats({
    instance: mockInstance,
    track,
    remoteId: nappConfig.remoteId
  })

  mockInstance.addEventListener('viewState.currentPageIndex.change', sendPageStats)

  setTimeout(() => {
    mockInstance.fire('viewState.currentPageIndex.change', 2)

    expect(track).toHaveBeenCalled()
    expect(track).toHaveBeenCalledWith(expected)

    done()
  }, 1000)

  expect(track).not.toHaveBeenCalled()
})

expect(track).toHaveBeenCalledWith(expected) fails with:

Expected mock function to have been called with:
      {"action": "PublicationPage", "category": "PublicationPage", "intervalInMilliseconds": 1000, "label": "7", "name": "n/a"}
    as argument 1, but it was called with
      {"action": "PublicationPage", "category": "PublicationPage", "intervalInMilliseconds": 1001, "label": "7", "name": "n/a"}

I have looked at jest-extended but I do not see anything useful for my use-case.


Solution 1:

This can be done with asymmetric matchers (introduced in Jest 18)

expect(track).toHaveBeenCalledWith(
  expect.objectContaining({
   "action": "PublicationPage", 
   "category": "PublicationPage", 
   "label": "7",
   "name": "n/a"
  })
)

If you use jest-extended you can do something like

expect(track).toHaveBeenCalledWith(
  expect.objectContaining({
   "action": "PublicationPage", 
   "category": "PublicationPage", 
   "label": "7",
   "name": "n/a",
   "intervalInMilliseconds": expect.toBeWithin(999, 1002)
  })
)

Solution 2:

You can access the expected object for a better assertion using track.mock.calls[0][0] (the first [0] is the invocation number, and the second [0] is the argument number). Then you could use toMatchObject to find partially match the object, avoiding the dynamic parameters such as intervalInMilliseconds.

Solution 3:

To re-iterate the comment by cl0udw4lk3r as I found this the most useful in my scenario:

If you have a method that accepts multiple parameters (not an object) and you only want to match some of these parameters then you can use the expect object.

Example

method I want to test:

client.setex(key, ttl, JSON.stringify(obj));

I want to ensure the correct values are passed into the key and ttl but I'm not concerned what the object passed in is. So I set up a spy:

const setexSpy = jest.spyOn(mockClient, "setex");

and I can then expect this scenario thus:

expect(setexSpy).toHaveBeenCalledWith('test', 99, expect.anything());

You can also use more strongly typed calls using expect.any (expect.any(Number)) etc.

Solution 4:

Of course I'm biased but I think this is the best and cleanest way. You can use the spread operator ... to expand the object you are checking then overwrite one or more values.

Here is an example showing how to overwrite the "intervalInMilliseconds" expected value to any Number

const track = jest.fn()

const expected = new PayloadTiming({
    category: 'PublicationPage',
    action: 'PublicationPage',
    name: 'n/a',
    label: '7',
    intervalInMilliseconds: 1000 // or around
  })

expect(track).toHaveBeenCalledWith(
  {
    ...expected, 
    intervalInMilliseconds: expect.any(Number)
  })

another example showing how to overwrite two values

expect(track).toHaveBeenCalledWith(
  {
    ...expected, 
    intervalInMilliseconds: expect.any(Number), 
    category: expect.any(String)
  })