How to mock functions in the same module using Jest?

Viewed 47753

What's the best way to correctly mock the following example?

The problem is that after import time, foo keeps the reference to the original unmocked bar.

module.js:

export function bar () {
    return 'bar';
}

export function foo () {
    return `I am foo. bar is ${bar()}`;
}

module.test.js:

import * as module from '../src/module';

describe('module', () => {
    let barSpy;

    beforeEach(() => {
        barSpy = jest.spyOn(
            module,
            'bar'
        ).mockImplementation(jest.fn());
    });


    afterEach(() => {
        barSpy.mockRestore();
    });

    it('foo', () => {
        console.log(jest.isMockFunction(module.bar)); // outputs true

        module.bar.mockReturnValue('fake bar');

        console.log(module.bar()); // outputs 'fake bar';

        expect(module.foo()).toEqual('I am foo. bar is fake bar');
        /**
         * does not work! we get the following:
         *
         *  Expected value to equal:
         *    "I am foo. bar is fake bar"
         *  Received:
         *    "I am foo. bar is bar"
         */
    });
});

I could change:

export function foo () {
    return `I am foo. bar is ${bar()}`;
}

to:

export function foo () {
    return `I am foo. bar is ${exports.bar()}`;
}

but this is pretty ugly in my opinion to do everywhere.

10 Answers

I had this same problem and due to the project's linting standards, defining a class or rewriting references in the exports were not code review approvable options even if not prevented by the linting definitions. What I stumbled on as a viable option is to use the babel-rewire-plugin which is much cleaner, at least in appearance. While I found this used in another project I had access to, I noticed it was already in an answer in a similar question which I have linked here. This is a snippet adjusted for this question (and without using spies) provided from the linked answer for reference (I also added semicolons in addition to removing spies because I'm not a heathen):

import __RewireAPI__, * as module from '../module';

describe('foo', () => {
  it('calls bar', () => {
    const barMock = jest.fn();
    __RewireAPI__.__Rewire__('bar', barMock);
    
    module.foo();

    expect(bar).toHaveBeenCalledTimes(1);
  });
});

https://stackoverflow.com/a/45645229/6867420

Works for me:

cat moduleWithFunc.ts

export function funcA() {
 return export.funcB();
}
export function funcB() {
 return false;
}

cat moduleWithFunc.test.ts

import * as module from './moduleWithFunc';

describe('testFunc', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  afterEach(() => {
    module.funcB.mockRestore();
  });

  it.only('testCase', () => {
    // arrange
    jest.spyOn(module, 'funcB').mockImplementationOnce(jest.fn().mockReturnValue(true));

    // act
    const result = module.funcA();

    // assert
    expect(result).toEqual(true);
    expect(module.funcB).toHaveBeenCalledTimes(1);
  });
});

From this thread:

Try using a function expression

export const bar = () => {
  return "bar"
}

This should let you spy on bar even if its used by another function in the same module.

There are various hacks available here to make this work, but the real answer most people should be using is: don't. Taking the OP's example module:

export function bar () {
    return 'bar';
}

export function foo () {
    return `I am foo. bar is ${bar()}`;
}

and testing the actual behaviour, you'd write:

import { bar, foo } from "path/to/module";

describe("module", () => {
    it("foo returns 'bar'", () => {
        expect(bar()).toBe('bar');
    });

    it("foo returns 'I am foo. bar is bar'", () => {
        expect(foo()).toBe('I am foo. bar is bar');
    });
});

Why? Because then you can refactor inside the module boundary without changing the tests, which gives you the confidence to improve the quality of your code in the knowledge that it still does what it's supposed to.

Imagine you extracted the creation of 'bar' from bar to an unexported function, for example:

function rawBar() {
    return 'bar';
}

export function bar () {
    return rawBar();
}

export function foo () {
    return `I am foo. bar is ${rawBar()}`;
}

The test I suggest above would pass. If you'd asserted that calling foo meant bar got called, that test would start failing, even though the refactor preserved the module's behaviour (same API, same outputs). That's an implementation detail.

Test doubles are for collaborators, if something really does need to be mocked here it should be extracted to a separate module (then mocking it is much easier, which tells you you're moving in the right direction). Trying to mock functions in the same module is like mocking parts of a class you're trying to test, which I illustrate similarly here: https://stackoverflow.com/a/66752334/3001761.

If you're using Babel (i.e. @babel/parser) to handle transpiling your code, the babel-plugin-explicit-exports-references1 npm package solves this pretty elegantly by making the "ugly" module.exports replacements for you transparently at transpile time. See the original problem thread for more information.


1 Note: I wrote this plugin!

For CommonJS modules users, suppose the file looks something like:

/* myModule.js */
function bar() {
  return "bar";
}

function foo() {
  return `I am foo. bar is ${bar()}`;
}

module.exports = { bar, foo };

You need to modify the file to:

/* myModule.js */
function bar() {
  return "bar";
}

function foo() {
  return `I am foo. bar is ${myModule.bar()}`;  // Change `bar()` to `myModule.bar()`
}

const myModule = { bar, foo };  // Items you wish to export

module.exports = myModule;  // Export the object

Your original test suite (myModule.test.js) should now pass:

const myModule = require("./myModule");

describe("myModule", () => {
  test("foo", () => {
    jest.spyOn(myModule, "bar").mockReturnValueOnce("bar-mock");

    const result = myModule.foo();
    expect(result).toBe("I am foo. bar is bar-mock");
  });
});

Read more: Mock/Spy exported functions within a single module in Jest

Related