javascripttypescriptjestjsmocking

Unable to mock a typescript module in jest correctly


I have a test I'm trying to run and I want to mock a module using jest.mock. However, I can't seem to get this one to work, in the example below, the original serializeProduct method is always invoked. I've tried spies, I've tried mocking the module. When I log out the variables that are the mocks/spies they are indeed mock functions at least so the mocking is happening. It's just that something is causing it to invoke that original method instead of the mock.

product.serializer.ts

import { ProductDto } from './product.dto';

export function serializeProducts(value: any[]): ProductDto[] {
    return value.map(serializeProduct);
}

export function serializeProduct(value: any) {
    return value;
}

product.serializer.spect.ts in the same folder as the above.

import { expect } from '@jest/globals';
import * as productsSerializer from './products.serializer';

describe('serializer', () => {
    afterEach(() => {
        jest.clearAllMocks();
        jest.restoreAllMocks();
    });

    describe('serializeProducts method', () => {
        it('should foo', () => {
            const packages = [1,2,3,];

            const spy = jest.spyOn(productsSerializer, 'serializeProduct').mockImplementation(() => undefined);

            productsSerializer.serializeProducts(packages);

            expect(spy).toHaveBeenCalledTimes(3);
        });
    });
});

I have also tried module mocking originally like so

jest.mock('./products.serializer', () => ({
  ...jest.requireActual('./products.serializer'),
  serializeProduct: jest.fn()
});

import { expect } from '@jest/globals';
import * as productsSerializer from './products.serializer';

describe('serializer', () => {
    afterEach(() => {
        jest.clearAllMocks();
        jest.restoreAllMocks();
    });

    describe('serializeProducts method', () => {
        it('should foo', () => {
            const packages = [1,2,3,];

            (productsSerializer.serializeProduct as jest.Mock).mockImplementation(() => undefined);

            productsSerializer.serializeProducts(packages);

            expect(productsSerializer.serializeProduct).toHaveBeenCalledTimes(3);
        });
    });
});

I've also individually imported the methods in the above example instead of importing * as to no avail.

I've got examples of this working in the very same project I'm working on and yet this one doesn't.

EDIT The accepted solution on the suggested other question is not viable as it requires changing application code to fix a test.

The other highest solution is to move exports around - also changing application code. I have confirmed that if I move one of the methods out it does work, but this really shouldn't be a viable solution either. It might work for some, but it doesn't make sense in the context of my application to do that.

So I would like to find out just why this does not work. If it should work then there should be a solution that doesn't involve changing application code.

EDIT 2 I have found that turning the methods into exported constants and defining them as arrow functions fixes the issue. Which means it probably has something to do with jest/node imports.


Solution

  • You're having trouble mocking serializeProduct because serializeProducts is calling it from within the same module. The root cause of this issue is how the code is transpiled.

    Let's take a look at the following example:

    export function foo() {};
    export function bar() { foo() };
    

    Your transpiler would most likely transform it to something like this (simplified):

    function foo() {};
    function bar() { foo() };
    
    exports.foo = foo;
    exports.bar = bar;
    

    The issue here is that we're mocking (reassigning) exports.foo in our tests, while bar is calling the original foo.

    One solution

    So, one thing we can do is call the exported foo property, aka, exports.foo, so that when it's mocked, we call the mock instead.

    export function foo() {};
    export function bar() { exports.foo() };
    

    This is valid code and will be correctly transpiled, but it's ugly.

    A better solution for you

    Since you're using Typescript, then there's a good chance you're using ts-jest as your transformer. ts-jest has a nice little quirk where it replaces exported variables with their exports member counterparts.

    So instead of calling a function we define using a function statement, we just need to call an exported variable that's assigned to the function we want, and that variable will be mockable because of ts-jest's helpful substitutions. We can do this in different ways:

    function foo () {};
    
    // All 3 of these are replaced by `exports.<name>` by `ts-jest` where they're called
    export const foo1 = foo; // Reassign to a new variable
    export const foo2 = function() {} // Anonymous function
    export const foo3 = () => {} // Arrow function
    

    Here's a quick illustration I got by debugging a test I ran locally. Let's see the original module, and how it was transformed by ts-jest.

    // Original
    export function foo() {};
    
    export const bar = function() {}
    
    export function buzz() {
      foo();
      bar();
    }
    
    // Transformed
    Object.defineProperty(exports, "__esModule", { value: true });
    exports.bar = void 0;
    exports.foo = foo;
    exports.buzz = buzz;
    function foo() {};
    const bar = () => {};
    exports.bar = bar;
    function buzz() {
        foo();
        (0, exports.bar)();
    }
    

    Notice in buzz how bar's function call was replaced by its exports counterpart. Now we can use spyOn with no issues.

    More reading: A github issue about this exact situation.

    Honorable mention - Dependency Injection

    For your particular case, serializeProducts is essentially a wrapper around Array.map which makes it a prime candidate for some dependency injection. You could give it a second parameter, pass it serializeProduct by default, and then pass it a mock function in your tests.

    It gives serializeProducts an added degree of flexibility without changing the overall workflow too much, and makes testing a lot easier.