Mocking classes in Jest does not call the same method

I'm trying to mock a class that is being imported into my code with require and then testing if a method of that class is getting called.

I've created a sample setup where this issue can be replicated:

// user.js
class User {
  getName() {
    return "Han Solo"
  }
}

module.exports = User
// user-consumer.js
const User = require('./user')
const user = new User()

module.exports.getUserName = () => {
  // do things here
  return user.getName() 
}
// user.test.js
const userConsumer = require('./user-consumer')
const User = require('./user')
jest.mock('./user')

it('should mock', () => {
  const user = new User()
  jest.spyOn(user, 'getName')
  userConsumer.getUserName()
  expect(user.getName).toBeCalled()
})

The error I get is as follows:

failed test

If I used ES6 syntax this would work as shown on jest's documentation: https://jestjs.io/docs/en/es6-class-mocks

But I unfortunately can't use ES6 on this project as it would require a lot of refactoring.

I also tried mocking the class with the module factory parameter

jest.mock('./user', () => {
  return jest.fn(() => {
    return {
      getName: jest.fn(),
    }
  })
})

It still doesn't work. When I log console.log(user.getName) in user-consumer.js:5 it does show that the method has been mocked but whatever is called in user.getName() is not the consumer function still returns "Han Solo".

I've also tried it with and without jest.spyOn and it still returns the same error.

Is this just not possible with none ES6 syntax?

1 answer

  • answered 2021-01-15 20:55 Estus Flask

    The problem is that Jest spies have undocumented behaviour.

    Even if prototype method is the same for all instances:

    new User().getName === new User().getName
    

    A spy is specific to an instance:

    jest.spyOn(new User(), 'getName') !== jest.spyOn(new User(), 'getName') 
    

    If a specific instance is unreachable, it's a prototype that needs to be spied:

    jest.spyOn(User.prototype, 'getName')
    userConsumer.getUserName()
    expect(User.prototype.getName).toBeCalled();
    

    A problem with jest.mock isn't specific to ES6 syntax. In order for a spy to be available for assertions and implementation changes, it should be exposed somewhere. Declaring it outside jest.mock factory is not a good solution as it can often result in race condition described in the manual; there will be one in this case too. A more safe approach is to expose a reference as a part of module mock.

    It would be more straightforward for ES module because this way class export is kept separately:

    import MockedUser, { mockGetName } from './user';
    
    jest.mock('./user', () => {
      const mockGetName = jest.fn();
      return {
        __esModule: true,
        mockGetName,
        default: jest.fn(() => {
          return {
            getName: mockGetName
          }
        })
      }
    })
    ...
    

    For CommonJS module with class (function) export, it will be efficiently exposed as class static method:

    import MockedUser from './user';
    
    jest.mock('./user', () => {
      const mockGetName = jest.fn();
      return Object.assign(
        jest.fn(() => {
          return {
            getName: mockGetName
          }
        }),
        { mockGetName }
      })
    })
    ...
    MockedUser.mockGetName.mockImplementation(...);
    userConsumer.getUserName()
    expect(MockedUser.mockGetName).toBeCalled();