Your Jest tests might be wrong

Is your Jest test suite failing you? You might not be using the testing framework’s full potential, especially when it comes to preventing state leakage between tests. The Jest settings clearMocks, resetMocks, restoreMocks, and resetModules are set to false by default. If you haven’t changed these defaults, your tests might be fragile, order-dependent, or just downright wrong. In this blog post, I’ll dig into what each setting does, and how you can fix your tests.

clearMocks

First up is clearMocks:

Automatically clear mock calls, instances, contexts and results before every test. Equivalent to calling jest.clearAllMocks() before each test. This does not remove any mock implementation that may have been provided.

Every Jest mock has some context associated with it. It’s how you’re able to call functions like mockReturnValueOnce instead of only mockReturnValue. But if clearMocks is false by default, then that context can be carried between tests.

Take this example function:

1export function randomNumber() {
2  return Math.random();
3}

And this simple test for it:

 1jest.mock('.');
 2
 3const { randomNumber } = require('.');
 4
 5describe('tests', () => {
 6    randomNumber.mockReturnValue(42);
 7  
 8    it('should return 42', () => {
 9        const random = randomNumber();
10    
11        expect(random).toBe(42);
12        expect(randomNumber).toBeCalledTimes(1)
13    });
14});

The test passes and works as expected. However, if we add another test to our test suite:

 1jest.mock('.');
 2
 3const { randomNumber } = require('.');
 4
 5describe('tests', () => {
 6    randomNumber.mockReturnValue(42);
 7  
 8    it('should return 42', () => {
 9        const random = randomNumber();
10    
11        expect(random).toBe(42);
12        expect(randomNumber).toBeCalledTimes(1)
13    });
14    
15    it('should return same number', () => {
16        const random1 = randomNumber();
17        const random2 = randomNumber();
18    
19        expect(random1).toBe(42);
20        expect(random2).toBe(42);
21    
22        expect(randomNumber).toBeCalledTimes(2)
23    });
24});

Our second test fails with the error:

1Error: expect(jest.fn()).toBeCalledTimes(expected)
2
3Expected number of calls: 2
4Received number of calls: 3

And even worse, if we change the order of our tests:

 1jest.mock('.');
 2
 3const { randomNumber } = require('.');
 4
 5describe('tests', () => {
 6    randomNumber.mockReturnValue(42);
 7  
 8    it('should return same number', () => {
 9        const random1 = randomNumber();
10        const random2 = randomNumber();
11    
12        expect(random1).toBe(42);
13        expect(random2).toBe(42);
14    
15        expect(randomNumber).toBeCalledTimes(2)
16    });
17  
18    it('should return 42', () => {
19        const random = randomNumber();
20    
21        expect(random).toBe(42);
22        expect(randomNumber).toBeCalledTimes(1)
23    });
24});

We get the same error as before, but this time for should return 42 instead of should return same number.

Enabling clearMocks in your Jest configuration ensures that every mock’s context is reset between tests. You can achieve the same result by adding jest.clearAllMocks() to your beforeEach() functions. But this isn’t a great idea as it means you have to remember to add it to each test file to make your tests safe, instead of using clearMocks to make them all safe by default.

resetMocks

Next up is resetMocks:

Automatically reset mock state before every test. Equivalent to calling jest.resetAllMocks() before each test. This will lead to any mocks having their fake implementations removed but does not restore their initial implementation.

resetMocks takes clearMocks a step further, by clearing the implementation of any mocks. However, you need to use it in addition to clearMocks.

Going back to my first example again, I’m going to move the mock setup inside the first test case randomNumber.mockReturnValue(42);.

 1describe('tests', () => {
 2    it('should return 42', () => {
 3        randomNumber.mockReturnValue(42);
 4        const random = randomNumber();
 5
 6        expect(random).toBe(42);
 7        expect(randomNumber).toBeCalledTimes(1)
 8    });
 9
10    it('should return 42 twice', () => {
11        const random1 = randomNumber();
12        const random2 = randomNumber();
13
14        expect(random1).toBe(42);
15        expect(random2).toBe(42);
16
17        expect(randomNumber).toBeCalledTimes(2)
18    });
19});

Logically, you might expect this to fail, but it passes! Jest mocks are global to the file they’re in. It doesn’t matter what describe, it, or test scope you use. And if I change the order of tests again, they fail. This makes it very easy to write tests that leak state and are order-dependent.

Enabling resetMocks in your Jest context ensures that every mock implementation is reset between tests. Like before, you can also add jest.resetAllMocks() to beforeEach() in every test file. But it’s a much better idea to make your tests safe by default instead of having to opt-in to safe tests.

restoreMocks

Next is restoreMocks:

Automatically restore mock state and implementation before every test. Equivalent to calling jest.restoreAllMocks() before each test. This will lead to any mocks having their fake implementations removed and restores their initial implementation.

restoreMocks takes test isolation and safety to the next level.

Let me rewrite my example a little bit, so instead of mocking the function directly, I’m mocking Math.random() instead.

 1const { randomNumber } = require('.');
 2
 3const spy = jest.spyOn(Math, 'random');
 4
 5describe('tests', () => {
 6    it('should return 42', () => {
 7        spy.mockReturnValue(42);
 8        const random = randomNumber();
 9
10        expect(random).toBe(42);
11        expect(spy).toBeCalledTimes(1)
12    });
13
14    it('should return 42 twice', () => {
15        spy.mockReturnValue(42);
16
17        const random1 = randomNumber();
18        const random2 = randomNumber();
19
20        expect(random1).toBe(42);
21        expect(random2).toBe(42);
22
23        expect(spy).toBeCalledTimes(2)
24    });
25});

With clearMocks and resetMocks enabled, and restoreMocks disabled, my tests pass. But if I enable restoreMocks both tests fail with an error message like:

1Error: expect(received).toBe(expected) // Object.is equality
2
3Expected: 42
4Received: 0.503533695686772

restoreMocks has restored the original implementation of Math.random() before each test, so now I’m getting an actual random number instead of my mocked return value of 42. This forces me to be explicit about not only the mocked return values I’m expecting, but the mocks themselves.

To fix my tests I can set up my Jest mocks in each individual test.

 1describe('tests', () => {
 2    it('should return 42', () => {
 3        const spy = jest.spyOn(Math, 'random').mockReturnValue(42);
 4        const random = randomNumber();
 5
 6        expect(random).toBe(42);
 7        expect(spy).toBeCalledTimes(1)
 8    });
 9
10    it('should return 42 twice', () => {
11        const spy = jest.spyOn(Math, 'random').mockReturnValue(42);
12
13        const random1 = randomNumber();
14        const random2 = randomNumber();
15
16        expect(random1).toBe(42);
17        expect(random2).toBe(42);
18
19        expect(spy).toBeCalledTimes(2)
20    });
21});

resetModules

Finally, we have resetModules:

By default, each test file gets its own independent module registry. Enabling resetModules goes a step further and resets the module registry before running each individual test. This is useful to isolate modules for every test so that the local module state doesn’t conflict between tests. This can be done programmatically using jest.resetModules().

Again, this builds on top of clearMocks, resetMocks, and restoreMocks. I don’t think this level of isolation is required for most tests, but I’m a completionist.

Let’s take my example from above and expand it to include some initialization that needs to happen before I can call randomNumber. Maybe I need to make sure there’s enough entropy to generate random numbers? My module might look something like this:

 1let isInitialized = false;
 2
 3export function initialize() {
 4    isInitialized = true;
 5}
 6
 7export function randomNumber() {
 8    if (!isInitialized) {
 9        throw new Error();
10    }
11
12    return Math.random();
13}

I also want to write some tests to make sure that this works as expected:

 1const random = require('.');
 2
 3describe('tests', () => {
 4    it('does not throw when initialized', () => {
 5        expect(() => random.initialize()).not.toThrow();
 6    });
 7
 8    it('throws when not initialized', () => {
 9        expect(() => random.randomNumber()).toThrow();
10    });
11});

initialize shouldn’t throw an error, but randomNumber should throw an error if initialize isn’t called first. Great! Except it doesn’t work. Instead I get:

1Error: expect(received).toThrow()
2
3Received function did not throw

That’s because without enabling resetModules, the module is shared between all tests in the file. So when I called random.initialize() in my first test, isInitialized is still true for my second test. But once again, if I were to switch the order of my tests in the file, they would both pass. So my tests are order-dependent again!

Enabling resetModules will give each test in the file a fresh version of the module for each test. Though, this might actually be a case where you want to use jest.resetAllModules() in your beforeEach() instead of enabling it globally. This kind of isolation isn’t required for every test. And if you’re using import instead of require, the syntax can get very awkward very quickly if you’re trying to avoid an 'import' and 'export' may only appear at the top level error.

TL;DR reset all of the things

By default, Jest tests are only isolated at the file level. If you really want to make sure your tests are safe and isolated, add this to your Jest config:

1{
2  clearMocks: true,
3  resetMocks: true,
4  restoreMocks: true,
5  resetModules: true // It depends
6}

There is a suggestion to make this part of the default configuration. But until then, you’ll have to do it yourself.

comments powered by Disqus