61

Mocking ES6 class imports

I'd like to mock my ES6 class imports within my test files.

If the class being mocked has multiple consumers, it may make sense to move the mock into __mocks__, so that all the tests can share the mock, but until then I'd like to keep the mock in the test file.

Jest.mock()

jest.mock() can mock imported modules. When passed a single argument:

jest.mock('./my-class.js');

it uses the mock implementation found in the __mocks__ folder adjacent to the mocked file, or creates an automatic mock.

The module factory parameter

jest.mock() takes a second argument which is a module factory function. For ES6 classes exported using export default, it's not clear what this factory function should return. Is it:

  1. Another function that returns an object that mimics an instance of the class?
  2. An object that mimics an instance of the class?
  3. An object with a property default that is a function that returns an object that mimics an instance of the class?
  4. A function that returns a higher-order function that itself returns 1, 2 or 3?

The docs are quite vague:

The second argument can be used to specify an explicit module factory that is being run instead of using Jest's automocking feature:

I'm struggling to come up with a factory definition that can function as a constructor when the consumer imports the class. I keep getting TypeError: _soundPlayer2.default is not a constructor (for example).

I've tried avoiding use of arrow functions (since they can't be called with new) and having the factory return an object that has a default property (or not).

Here's an example. This is not working; all of the tests throw TypeError: _soundPlayer2.default is not a constructor.

Class being tested: sound-player-consumer.js

import SoundPlayer from './sound-player'; // Default import

export default class SoundPlayerConsumer {
  constructor() {
    this.soundPlayer = new SoundPlayer(); //TypeError: _soundPlayer2.default is not a constructor
  }

  playSomethingCool() {
    const coolSoundFileName = 'song.mp3';
    this.soundPlayer.playSoundFile(coolSoundFileName);
  }
}

Class being mocked: sound-player.js

export default class SoundPlayer {
  constructor() {
    // Stub
    this.whatever = 'whatever';
  }

  playSoundFile(fileName) {
    // Stub
    console.log('Playing sound file ' + fileName);
  }
}

The test file: sound-player-consumer.test.js

import SoundPlayerConsumer from './sound-player-consumer';
import SoundPlayer from './sound-player';

// What can I pass as the second arg here that will 
// allow all of the tests below to pass?
jest.mock('./sound-player', function() { 
  return {
    default: function() {
      return {
        playSoundFile: jest.fn()
      };
    }
  };
});

it('The consumer should be able to call new() on SoundPlayer', () => {
  const soundPlayerConsumer = new SoundPlayerConsumer();
  expect(soundPlayerConsumer).toBeTruthy(); // Constructor ran with no errors
});

it('We can check if the consumer called the mocked class constructor', () => {
  const soundPlayerConsumer = new SoundPlayerConsumer();
  expect(SoundPlayer).toHaveBeenCalled();
});

it('We can check if the consumer called a method on the class instance', () => {
  const soundPlayerConsumer = new SoundPlayerConsumer();
  const coolSoundFileName = 'song.mp3';
  soundPlayerConsumer.playSomethingCool();
  expect(SoundPlayer.playSoundFile).toHaveBeenCalledWith(coolSoundFileName);
});

What can I pass as the second arg to jest.mock() that will allow all of the tests in the example pass? If the tests need to be modified that's okay - as long as they still test for the same things.

stone
  • 8,422
  • 5
  • 54
  • 66

5 Answers5

73

Updated with a solution thanks to feedback from @SimenB on GitHub.


Factory function must return a function

The factory function must return the mock: the object that takes the place of whatever it's mocking.

Since we're mocking an ES6 class, which is a function with some syntactic sugar, then the mock must itself be a function. Therefore the factory function passed to jest.mock() must return a function; in other words, it must be a higher-order function.

In the code above, the factory function returns an object. Since calling new on the object fails, it doesn't work.

Simple mock you can call new on:

Here's a simple version that, because it returns a function, will allow calling new:

jest.mock('./sound-player', () => {
  return function() {
    return { playSoundFile: () => {} };
  };
});

Note: Arrow functions won't work

Note that our mock can't be an arrow function because we can't call new on an arrow function in Javascript; that's inherent in the language. So this won't work:

jest.mock('./sound-player', () => {
  return () => { // Does not work; arrow functions can't be called with new
    return { playSoundFile: () => {} };
  };
});

This will throw TypeError: _soundPlayer2.default is not a constructor.

Keeping track of usage (spying on the mock)

Not throwing errors is all well and good, but we may need to test whether our constructor was called with the correct parameters.

In order to track calls to the constructor, we can replace the function returned by the HOF with a Jest mock function. We create it with jest.fn(), and then we specify its implementation with mockImplementation().

jest.mock('./sound-player', () => {
  return jest.fn().mockImplementation(() => { // Works and lets you check for constructor calls
    return { playSoundFile: () => {} };
  });
});

This will let us inspect usage of our mocked class, using SoundPlayer.mock.calls.

Spying on methods of our class

Our mocked class will need to provide any member functions (playSoundFile in the example) that will be called during our tests, or else we'll get an error for calling a function that doesn't exist. But we'll probably want to also spy on calls to those methods, to ensure that they were called with the expected parameters.

Because a new mock object will be created during our tests, SoundPlayer.playSoundFile.calls won't help us. To work around this, we populate playSoundFile with another mock function, and store a reference to that same mock function in our test file, so we can access it during tests.

let mockPlaySoundFile = jest.fn();
jest.mock('./sound-player', () => {
  return jest.fn().mockImplementation(() => { // Works and lets you check for constructor calls
    return { playSoundFile: mockPlaySoundFile }; // Now we can track calls to playSoundFile
  });
});

Complete example

Here's how it looks in the test file:

import SoundPlayerConsumer from './sound-player-consumer';
import SoundPlayer from './sound-player';

let mockPlaySoundFile = jest.fn();
jest.mock('./sound-player', () => {
  return jest.fn().mockImplementation(() => {
    return { playSoundFile: mockPlaySoundFile };
  });
});

it('The consumer should be able to call new() on SoundPlayer', () => {
  const soundPlayerConsumer = new SoundPlayerConsumer();
  expect(soundPlayerConsumer).toBeTruthy(); // Constructor ran with no errors
});

it('We can check if the consumer called the class constructor', () => {
  const soundPlayerConsumer = new SoundPlayerConsumer();
  expect(SoundPlayer).toHaveBeenCalled();
});

it('We can check if the consumer called a method on the class instance', () => {
  const soundPlayerConsumer = new SoundPlayerConsumer();
  const coolSoundFileName = 'song.mp3';
  soundPlayerConsumer.playSomethingCool();
  expect(mockPlaySoundFile.mock.calls[0][0]).toEqual(coolSoundFileName);
});
stone
  • 8,422
  • 5
  • 54
  • 66
  • 25
    I still getting `TypeError: ...default is not a constructor`. – Pablo Apr 18 '18 at 12:58
  • 1
    I or someone else in the community may be able to help you if you give us some details about what exactly you're doing when you get that error. Maybe start a new question? Also this doc may help: https://facebook.github.io/jest/docs/en/es6-class-mocks.html – stone Apr 19 '18 at 06:47
  • 2
    Something that seemed insignificant that was causing the constructors to lack definition after mocking as described - the ES6 classes were not default exports. Couldn't get anything to work until I updated the mocked classes to be default exports and then updated imports respectively. Afterwords the mocks worked. – msg45f Sep 25 '18 at 01:25
  • how would you mock {playSoundFile: mockPlaySoundFile} with different inputs? like in one scenario it returns the expect value and in second it throws error. do we need a separate test file for that? – valearner Aug 27 '20 at 02:23
  • 1
    @valearner in such cases you could either override the mock inside your test case or chain mockReturnValueOnce() calls. See https://jestjs.io/docs/en/mock-function-api#mockfnmockreturnvalueoncevalue for more info. – 99linesofcode Jan 27 '21 at 13:51
  • Because of hoisting of `jest.mock()`, shouldn't this produce a `ReferenceError: Cannot access 'mockPlaySoundFile' before initialization` error? – dossy Jul 12 '23 at 23:24
  • @dossy by the time the function actually gets called, `mockPlaySoundFile` will exist. `Since calls to jest.mock() are hoisted to the top of the file, Jest prevents access to out-of-scope variables. By default, you cannot first define a variable and then use it in the factory. Jest will disable this check for variables that start with the word mock. However, it is still up to you to guarantee that they will be initialized on time.` – stone Jul 14 '23 at 22:27
  • @stone I ran into an issue where, I believe, SWC hoists things differently than Babel/ts-jest, and it resulted in different behavior. – dossy Jul 16 '23 at 00:23
73

If you are still getting TypeError: ...default is not a constructor and are using TypeScript keep reading.

TypeScript is transpiling your ts file and your module is likely being imported using ES2015s import. const soundPlayer = require('./sound-player'). Therefore creating an instance of the class that was exported as a default will look like this: new soundPlayer.default(). However if you are mocking the class as suggested by the documentation.

jest.mock('./sound-player', () => {
  return jest.fn().mockImplementation(() => {
    return { playSoundFile: mockPlaySoundFile };
  });
});

You will get the same error because soundPlayer.default does not point to a function. Your mock has to return an object which has a property default that points to a function.

jest.mock('./sound-player', () => {
    return {
        default: jest.fn().mockImplementation(() => {
            return {
                playSoundFile: mockPlaySoundFile 
            }   
        })
    }
})

For named imports, like import { OAuth2 } from './oauth', replace default with imported module name, OAuth2 in this example:

jest.mock('./oauth', () => {
    return {
        OAuth2: ... // mock here
    }
})
Maxim Mazurok
  • 3,856
  • 2
  • 22
  • 37
  • 3
    Thanks a lot! In my case, I'm using named import `import { OAuth2 } from '...'`. And I just replaced `default:` from your answer with `OAuth2:` and it worked! – Maxim Mazurok Aug 16 '19 at 15:18
  • Thanks, @seebiscuit, I'm glad my comment helped. But nidkil answer already covered named imports in his repo: https://github.com/nidkil/jest-test/blob/master/test/es6-classes/named/auth-service-class.on.the.fly.named.mock.classes.spec.js – Maxim Mazurok Mar 08 '20 at 02:32
  • 1
    @MaximMazurok by many standards a link is not an Answer. In the future the repo may not exist. Also, it's not completely clear where the correct solution may be in a repo. A high quality answer includes relevant information in the post. – seebiscuit Mar 08 '20 at 17:16
  • 1
    Thank you! It was so frustrating reading the official docs, which use the same example as the currently accepted answer, which doesn't work at all for a named import. Using the actual class name (as in your OAuth2 example) worked perfectly. – 404 Jul 28 '22 at 15:40
  • Damn I submitted a bug related to this and this answer helped me more than the people who maintain Jest. I mean, they helped, but not as much as this. – jcollum Sep 29 '22 at 21:33
  • In my case, I found that if I was returning a "default" property on the mocked module, I also had to have a property called __esModule set to true. I also discovered that for me I could return the object directly without using the "default" property. It had to be both or neither, though. – Ken Lyon Dec 02 '22 at 17:34
2

Stone and Santiago helped me with this. I just wanted mention that in addition, I had to stick the jest mock function before my import statements like this:

jest.mock('bootstrap/dist/js/bootstrap.esm.js', () => {
    return {
        Tooltip: function(init){
            this.init = init;
        }
    }
})

import { newSpecPage } from '@stencil/core/testing';
import { CoolCode } from '../cool-code';

Thanks for the help!

Omar
  • 421
  • 4
  • 10
  • 2
    I dont think this should be needed. The [jest docs specifically mention](https://jestjs.io/docs/es6-class-mocks#calling-jestmock-with-the-module-factory-parameter) `...calls to jest.mock() are hoisted to the top of the file...`. I.e. you shouldnt have to do this manually, jest should take care of this automatically. – devklick Apr 26 '22 at 17:12
  • For me the order did not matter. However, jest.mock only worked on the top scope and not when moved inside describe or it block. – Stefan Dec 21 '22 at 13:28
1

If you have defined a mocking class, you can use something like:

jest.mock("../RealClass", () => {
  const mockedModule = jest.requireActual(
    "../path-to-mocked-class/MockedRealClass"
  );
  return {
    ...mockedModule,
  };
});

The code will do something like replacing method and property definitions of the original RealClass, with the one of MockedRealClass.

vencedor
  • 663
  • 7
  • 9
  • The above code is effectively the same as having jest automatically mock RealClass but with more code that adds no value. – 99linesofcode Jan 28 '21 at 10:47
  • Scroll down to the answer by @stone for a detailed breakdown of the how and the why behind manually mocking ES6 classes in place. – 99linesofcode Jan 28 '21 at 10:48
  • @99linesofcode The code by "stone did not work for me, but the code I put out works in my case. – vencedor Jan 28 '21 at 11:16
  • If you want to auto mock a module, which is essentially all you are doing here, you can simply replace the code with jest.mock("../RealClass"); – 99linesofcode Jan 28 '21 at 12:19
  • 1
    @99linesofcode I think you don't get the idea behind "../path-to-mocked-class/MockedRealClass", this is a place where you put a "mocking class", that describes SOME of the functionality of the REAL class.It is not the real class. It is file that defines methods that just do imitation of the real class methods. And is written by the developer, not auto-generated, not auto-mocked. – vencedor Jan 28 '21 at 12:47
0

This worked for me:

export default class SoundPlayer {
  constructor() {
    // Stub
    this.whatever = 'whatever';
  }

  playSoundFile(fileName) {
    // Stub
    console.log('Playing sound file ' + fileName);
  }
}

Then in test, return SoundPlayer as key object with jest.fn().mockImplementation(()

 jest.mock('./sound-player', () => {
          return {
            SoundPlayer: jest.fn().mockImplementation(() => {
              return { 
                playSoundFile: mockPlaySoundFile 
              };
          })
       }
    });
Jan Ranostaj
  • 136
  • 7