Search code examples
typescriptjestjsts-jestbullmq

Mocking classes imported from node_modules


I am having a hard time using mocks for BullMQ in NodeJS with Typescript. I follow the instructions, the very related example and this question.

The test case focuses on the initial setup that instantiates 3 Queues, adds a job in each of them and starts the corresponding workers. Note that the source code instantiates a Queue with: import {Queue} from 'bullmq'; and const newQueue = new Queue(...). Then job is added with newQueue.add(...).

Problem: I cannot use a reusable mock object that can be defined in __mocks__ directory (Manual mock in the documentation). I use:

const mockAdd = jest.fn(() => console.log('test'));

const mockQueue = jest.fn(function () {
  return {
    add: mockAdd,
  };
});

const mockBullmq = jest.fn(function () {
  return {
    Queue: mockQueue,
  };
});

export { mockAdd, mockQueue, mockBullmq as default};

The result is TypeError: bullmq_1.Queue is not a constructor.

Because I use typescript ("target": "ES2020") with named import I also followed the advice in the first answer here with no luck. The used code follows:

const mockAdd = jest.fn(() => console.log('test'));

const mockQueue = jest.fn(function () {
  return {
    add: mockAdd,
  };
});

const mockBullmq = jest.mock('./bullmq', function () {
  return {
    Queue: mockQueue,
  };
});

export { mockAdd, mockQueue, mockBullmq as default};

The test case is the following:

import queue from '@worker/queue';
import { Queue, Worker, add } from '@mocks/bullmq';

describe('Test queue setup:', () => {
  it('should connect to queues', async () => {
    await queue.setup();
    expect(Queue).toHaveBeenCalledTimes(3);
    expect(Queue.mock.instances).toHaveLength(3);
    expect(add).toHaveBeenCalledTimes(3);
    expect(Queue.mock.instances[0].add).toHaveBeenCalledTimes(1);
    expect(Worker).toHaveBeenCalledTimes(3);
  });
});

Solution

  • No, you kinda had the right idea from my comment but not exactly. Honestly, you really need to read through the mocks docs and see how they say to do it because there are so many nuances based on each use case.

    The general idea for mocking node_modules is that any matching module inside of __mocks__/ is automatically used to mock the module in all tests regardless of whether automock is true or whether you explicitly call jest.mock('my-module') or not. See docs here for more details.

    So taking a look at your case I am going to simplify things a bit because you didn't provide the setup logic, but I'm confident you can apply this to your exact case. But imagine I have this setup.ts file I want to test below...

    // src/setup.ts
    
    import { Queue } from 'bullmq';
    
    export function setup() {
        const queue1 = new Queue('default1');
        queue1.add('name1', {})
        const queue2 = new Queue('default2');
        queue2.add('name2', {})
        const queue3 = new Queue('default3');
        queue3.add('name3', {})
    }
    

    Then the tests I would then write as such...

    // src/setup.test.ts
    
    import { Queue } from 'bullmq';
    import { setup } from './setup';
    
    import { mocks } from '../__mocks__/bullmq';
    
    // jest.mock('bullmq'); // not needed
    
    describe('Test queue setup:', () => {
        it('should connect to queues', async () => {
            setup();
            expect(Queue).toHaveBeenCalledTimes(3);
            expect((Queue as unknown as jest.Mock).mock.instances).toHaveLength(3);
            expect(mocks.add).toHaveBeenCalledTimes(3);
        });
    });
    

    There are a few key things to note here. The first is how I import the bullmq mocks. Even though I am importing from bullmq as I normally would, this is actually importing the module from __mocks__/bullmq if it exists, I'll get to this file later. But you don't import directly from __mocks__/ jest rewires this import under the hood. This applies to any import of this module in both tests and src/ files!

    The next thing is why am I importing mocks from ../__mocks__/bullmq? I'll explain this more later but this is essentially a way to import other things that are not exports of the actual bullmq module.

    Next is the jest.mock('bullmq') line, as I mentioned in the beginning this is not needed for mocking node_modules, see docs.

    Next is the (Queue as unknown as jest.Mock) type gymnastics, this is a way to coerce the default import type to be of the type Mock. The issue is that by importing the module directly from the bullmq the original types are used not the mocked types. But since we know there is a mock we must override this type as the underlying javascript object is in fact a mock, it just doesn't know it. There may be a better way to handle this but this is the quick and easy way.

    Finally I removed the line you had as...

    expect(Queue.mock.instances[0].add).toHaveBeenCalledTimes(1)
    

    In order to access the instance methods you must build the mock differently by applying the methods to the this context, see the SO question for more.

    So now for the __mocks__ file. Here the file name must match the module name, I think you could also do __mocks__/bullmq/index.ts if you'd like to organize your mock better.

    // __mocks__/bullmq.ts
    const add = jest.fn();
    
    export const Queue = jest.fn(function() {
        console.log('mock Queue called')
    
        // This is done in order to access `mockFn.mock.instances`
        this.add = add;
        this.obliterate = jest.fn();
        this.getRepeatableJobs = jest.fn(() => []);
    });
    
    export const mocks = {
        add
    };
    

    Notice the add method is shared across all instances that are created using the Queue mock. Then I just add all these erroneous exports under mocks export. If I want to access the add method from inside a test file, I can import it using import { mocks } from '../__mocks__/bullmq' and using it from mocks.add, see how I did this in setup.test.ts. If you were to import add from bullmq you would get a type error as the original types don't have this export.

    Back to the line about expect(Queue.mock.instances[0].add).toHaveBeenCalledTimes(1), I removed this as this would produce the same mock instance as the mocks.add for all instances of Queue. So maybe you can tweak this to assert what you wanted but it doesn't make sense as it was.

    A helpful tip is adding console.log in your mocks to debug and ensure that they are actually being called.

    I created a demo replit here. You can open the shell tab on the right and run npm run test to see the tests pass. The output is shown as...

    enter image description here