Multiple calls to Jest mock returns last call props for all calls

In JavaScript, when you pass an object as an argument to a function, the function receives a reference to the original object in memory, rather than a copy of the object. This means that any changes made to the object within the function will affect the original object outside of the function as well.

This behavior can sometimes lead to unexpected results when using Jest's or Vitest mocking features, particularly when examining the mock.calls array. In this blog post, we will explore why mock.calls holds references to actual values when a function is called twice with an object argument.

Let's start with an example to demonstrate this behavior. Suppose we have a function called getUserByID that takes an object representing a user and gets some data from a database. We want to test this function using Jest's mocking features to ensure that it is working as expected. Here's our initial test:

1class UserList {
2 constructor(getUserByID) {
3 this.getUserByID = getUserByID
4 }
5 getAll() {
6 let indexPara = { id: undefined }
7 this.getUserByID(indexPara)
8 }
9}
10
11
12it('mockCalls', async () => {
13
14 const getUserByID = vi.fn()
15
16 const ul = new UserList(getUserByID)
17 getUserByID.mockImplementation((props) => {
18 Promise.resolve(`User ${props.id}`)
19 })
20
21 ul.getAll()
22
23 expect(getUserByID).toHaveBeenCalledTimes(1)
24 // equal to: expect(getUserByID.mock.calls).toEqual([[{id:0}]])
25 expect(getUserByID).toHaveBeenNthCalledWith(1, { id: 0 })
26
27})

In this test, we create a mock of the function using Jest's jest.fn() method and pass it to the contructor of the UserList class . We then use Jest's toHaveBeenNthCalledWith() and toHaveBeenCalledTimes() matchers to ensure that the getUserByID function was called once with the correct object with the user id.

Now, suppose we want to modify this test to call getUserByID twice with different user id. We might expect that the mock.calls array would hold references to two separate user objects, one for each call. However, let's see what actually happens when we run the modified test:

1class UserList {
2 constructor(getUserByID) {
3 this.getUserByID = getUserByID
4 }
5 getAll() {
6 let indexPara = { id: 1 }
7 this.getUserByID(indexPara)
8 indexPara.id = 2
9 this.getUserByID(indexPara)
10 }
11}
12
13
14it('mockCalls', async () => {
15
16 const getUserByID = vi.fn()
17
18 const ul = new UserList(getUserByID)
19 getUserByID.mockImplementation((props) => {
20 Promise.resolve(`User ${props.id}`)
21 })
22
23 ul.getAll()
24
25 expect(getUserByID).toHaveBeenCalledTimes(2)
26 // equal to: expect(getUserByID.mock.calls).toEqual([[{id:0}],[{id:1}]])
27 expect(getUserByID).toHaveBeenNthCalledWith(1, { id: 1 }) // AssertionError: expected 1st "spy" call to have been called with \[ { id: +1 } \]
28 expect(getUserByID).toHaveBeenNthCalledWith(2, { id: 2 })
29
30})

In this test, we call getUserByID twice with two different user objects, {id:1} and {id:2}. We then use Jest's toHaveBeenNthCalledWith() and toHaveBeenCalledTimes() matchers to ensure that updateUser was called twice with the correct arguments. Finally, we examine the mock.calls array to compare the user objects passed to each call.

AssertionError: expected 1st "spy" call to have been called with \[ { id: +1 } \]

Expected: [ [ {id: 0} ], [ {id: 1} ] ] Received: [ [ {id: 1} ], [ {id: 1} ] ]

What we find is that the mock.calls array holds references to the actual user objects passed to the function, rather than copies of the objects. This means that the two elements in the mock.calls array are references to the same object in memory, rather than two separate objects with identical values. Which in all cases leads to a result which returns object passed to the last call for all calls.

This behavior can be surprising at first, especially if you are used to working with primitive types like strings or numbers, which are passed by value rather than by reference. However, it is important to keep in mind when working with Jest's mocking features.

To work around this behavior and ensure that mock.calls holds separate objects for each call, you can create copies of the objects before passing them to the function, like so:

There are two ways to fix the problem:

  1. avoid to call function with object references
  2. modify your test code to capture the function calls

To be clear - the code we tested is working and not broken in any way! It is our test which fails to capture the bits we need to test the code.

Change the application code

The part in the code which breaks the test is here:

1getAll() {
2 let indexPara = { id: 1 }
3 this.getUserByID(indexPara) // passing a reference to the object
4 indexPara.id = 2 // modifying
5 this.getUserByID(indexPara) // passing the same reference
6 }

In the code we pass the reference of the object to our mocked function.

One of many solutions is to copy the object to create a new object:

1getAll() {
2 let indexPara = { id: 1 }
3 this.getUserByID({...indexPara}) // passing a reference to the object
4 indexPara.id = 2 // modifying
5 this.getUserByID({...indexPara}) // passing the same reference
6 }

Many time we do not want to change our code to fix problems in the tests and in some cases the part you mock is called by an external library.

Fix the problem in your tests

1it('mockCalls', async () => {
2
3 const getUserByID = vi.fn()
4
5 const ul = new UserList(getUserByID)
6 let mockCalls=[] // 1. create a store for the call history
7 getUserByID.mockImplementation((props) => {
8 mockCalls.push(props) // 2. push the props to the history
9 Promise.resolve(`User ${props.id}`)
10 })
11
12 ul.getAll()
13
14 expect(getUserByID).toHaveBeenCalledTimes(2)
15 // replace with your own call history
16 expect(mockCalls).toEqual([{ id: 1 },{ id: 2 }])
17 //expect(getUserByID).toHaveBeenNthCalledWith(1, { id: 1 })
18 //expect(getUserByID).toHaveBeenNthCalledWith(2, { id: 2 })
19
20})
  1. let mockCalls=[] - create a store for the call history
  2. mockCalls.push(props) - push the props to the history
  3. expect(mockCalls).toEqual([{ id: 1 },{ id: 2 }]) - validate the call history

References: