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 = getUserByID4 }5 getAll() {6 let indexPara = { id: undefined }7 this.getUserByID(indexPara)8 }9}101112it('mockCalls', async () => {1314 const getUserByID = vi.fn()1516 const ul = new UserList(getUserByID)17 getUserByID.mockImplementation((props) => {18 Promise.resolve(`User ${props.id}`)19 })2021 ul.getAll()2223 expect(getUserByID).toHaveBeenCalledTimes(1)24 // equal to: expect(getUserByID.mock.calls).toEqual([[{id:0}]])25 expect(getUserByID).toHaveBeenNthCalledWith(1, { id: 0 })2627})
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 = getUserByID4 }5 getAll() {6 let indexPara = { id: 1 }7 this.getUserByID(indexPara)8 indexPara.id = 29 this.getUserByID(indexPara)10 }11}121314it('mockCalls', async () => {1516 const getUserByID = vi.fn()1718 const ul = new UserList(getUserByID)19 getUserByID.mockImplementation((props) => {20 Promise.resolve(`User ${props.id}`)21 })2223 ul.getAll()2425 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 })2930})
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:
- avoid to call function with object references
- 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 object4 indexPara.id = 2 // modifying5 this.getUserByID(indexPara) // passing the same reference6 }
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 object4 indexPara.id = 2 // modifying5 this.getUserByID({...indexPara}) // passing the same reference6 }
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 () => {23 const getUserByID = vi.fn()45 const ul = new UserList(getUserByID)6 let mockCalls=[] // 1. create a store for the call history7 getUserByID.mockImplementation((props) => {8 mockCalls.push(props) // 2. push the props to the history9 Promise.resolve(`User ${props.id}`)10 })1112 ul.getAll()1314 expect(getUserByID).toHaveBeenCalledTimes(2)15 // replace with your own call history16 expect(mockCalls).toEqual([{ id: 1 },{ id: 2 }])17 //expect(getUserByID).toHaveBeenNthCalledWith(1, { id: 1 })18 //expect(getUserByID).toHaveBeenNthCalledWith(2, { id: 2 })1920})
let mockCalls=[]
- create a store for the call historymockCalls.push(props)
- push the props to the historyexpect(mockCalls).toEqual([{ id: 1 },{ id: 2 }])
- validate the call history
References: