Home > Net >  Jest - replacing mock implementation for named exported function not working
Jest - replacing mock implementation for named exported function not working

Time:10-21

I have the following code of the NodeJS AWS Lambda function I would like to cover by unit tests with Jest:

index.js

const { getConfig } = require('./modules/config/configManager');
const BridgeDataProvider = require('./modules/storage/bridge/bridgeDataProvider');
const ClientProcessor = require('./modules/domain/clientProcessor');
const configPromise = getConfig();

exports.handler = async () => {

    const config = await configPromise;
    if (!config.enabled) {
        return;
    }

    const bridgeDataProvider = new BridgeDataProvider(config.bridgeDynamoDbConfig);
    const clientProcessor = new ClientProcessor(config);

    const clients = await bridgeDataProvider.getActiveClients();

    for (const client of clients) {
        await clientProcessor.process(client);
    }
};

And I'm trying to mock getConfig async function for each test individually, but unfortunately it doesn't work. await getConfig() in index.js always returns undefined Here's the testing code:

index.test.js

const { getConfig } = require('../modules/config/configManager');
const { handler } = require('../index');
const BridgeDataProvider = require('../modules/storage/bridge/bridgeDataProvider');
const ClientProcessor = require('../modules/domain/clientProcessor');

jest.mock('../modules/config/configManager', () => ({
    getConfig: jest.fn(),
}));
jest.mock('../modules/storage/bridge/bridgeDataProvider');
jest.mock('../modules/domain/clientProcessor');

const defaultMocks = {
    BridgeDataProvider: {
        getActiveClients: jest.fn().mockImplementation(() => {
            /** @type {Client[]} */
            const clients = [
                {
                    id: '1234abc',
                    clientcode: 'Amazon',
                },
                {
                    id: '5678def',
                    clientcode: 'Facebook',
                },
            ];

            return Promise.resolve(clients);
        }),
    },
};

/**
 * @typedef {import('../modules/config/config.types').AppConfig} AppConfig
 * @typedef {import('../modules/storage/bridge/models.types').Client} Client
 */

describe('handler', () => {
    beforeEach(() => {
        jest.resetModules()
            .clearAllMocks();
        
        setupDefaultMocks();
    });

    it('does not handle clients if function is not enabled', async () => {
        getConfig.mockResolvedValue({enabled: false, bridgeDynamoDbConfig: {}}); // this is not working, await getConfig() returns undefined in index.js

        await handler();

        const processMethod = ClientProcessor.mock.instances[0].process;
        expect(processMethod).toHaveBeenCalledTimes(0);
    });
});

function setupDefaultMocks() {
    getConfig.mockResolvedValue({enabled: true, bridgeDynamoDbConfig: {}}); // this is not working, await getConfig() returns undefined in index.js

    BridgeDataProvider.mockImplementation(() => defaultMocks.BridgeDataProvider);
}

Test output:

 FAIL  test/index.test.js
  ● handler › does not handle clients if function is not enabled

    TypeError: Cannot read properties of undefined (reading 'enabled')

      10 |
      11 |     const config = await configPromise;
    > 12 |     if (!config.enabled) {
         |                 ^
      13 |         return;
      14 |     }
      15 |

      at enabled (index.js:12:17)
      at Object.<anonymous> (test/index.test.js:48:9)

If I put default implementation right inside jest.mock('../modules/config/configManager', ...) statement, it will mock resolved value as expected. Why is the mocking in individual test not working and how to make it work?


Thanks to @slideshowp2, he pointed me to the fact that getConfig is called at the module scope, and I need to require handler after doing the mock of getConfig. However, if I try to add two tests with differently mocked getConfig (config.enabled = true and config.enabled = false), second test will get the same enabled = true. I put the example below based on the @slideshowp2's answer. I belive I need to remove the index.js module cache after each test. I would be grateful if someone would show how to do it.

index.test.js

const { getConfig } = require('../modules/config/configManager');
const BridgeDataProvider = require('../modules/storage/bridge/bridgeDataProvider');
const ClientProcessor = require('../modules/domain/clientProcessor');

jest.mock('../modules/config/configManager');
jest.mock('../modules/storage/bridge/bridgeDataProvider');
jest.mock('../modules/domain/clientProcessor');

describe('index', () => {

    test('should pass (enabled=true)', async () => {
        getConfig.mockResolvedValueOnce({ enabled: true, bridgeDynamoDbConfig: 'fake bridge dynamoDB config' });
        const bridgeDataProviderInstance = {
            getActiveClients: jest.fn().mockResolvedValueOnce([1, 2])
        };
        BridgeDataProvider.mockImplementation(() => bridgeDataProviderInstance);

        const clientProcessorInstance = {
            process: jest.fn()
        };
        ClientProcessor.mockImplementation(() => clientProcessorInstance);

        const {handler} = require('../index');
        await handler();
        expect(getConfig).toBeCalledTimes(1);
        expect(BridgeDataProvider).toBeCalledWith('fake bridge dynamoDB config');
        expect(bridgeDataProviderInstance.getActiveClients).toBeCalledTimes(1);
        expect(clientProcessorInstance.process.mock.calls).toEqual([[1], [2]]);
    });

    test('should pass (enabled=false)', async () => {
        getConfig.mockResolvedValueOnce({ enabled: false, bridgeDynamoDbConfig: 'fake bridge dynamoDB config' });
        const bridgeDataProviderInstance = {
            getActiveClients: jest.fn().mockResolvedValueOnce([1, 2])
        };
        BridgeDataProvider.mockImplementation(() => bridgeDataProviderInstance);

        const clientProcessorInstance = {
            process: jest.fn()
        };
        ClientProcessor.mockImplementation(() => clientProcessorInstance);

        const {handler} = require('../index');
        await handler();
        expect(clientProcessorInstance.process).toBeCalledTimes(0);
    });
});

Test output

FAIL  test/index2.test.js
  index
    √ should pass (enabled=true) (234 ms)
    × should pass (enabled=false) (3 ms)

  ● index › should pass (enabled=false)

    expect(jest.fn()).toBeCalledTimes(expected)

    Expected number of calls: 0
    Received number of calls: 2

      43 |         const {handler} = require('../index');
      44 |         await handler();
    > 45 |         expect(clientProcessorInstance.process).toBeCalledTimes(0);
         |                                                 ^
      46 |     });
      47 | });

      at Object.toBeCalledTimes (test/index2.test.js:45:49)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        2.95 s, estimated 4 s

CodePudding user response:

See es6-class-mocks#replacing-the-mock-using-mockimplementation-or-mockimplementationonce:

Note: you call getConfig in the module scope, so make sure you mock its resolved value before the require('./') statement.

Solution:

index.js:

const { getConfig } = require('./modules/config/configManager');
const BridgeDataProvider = require('./modules/storage/bridge/bridgeDataProvider');
const ClientProcessor = require('./modules/domain/clientProcessor');
const configPromise = getConfig();

exports.handler = async () => {

  const config = await configPromise;
  if (!config.enabled) {
    return;
  }

  const bridgeDataProvider = new BridgeDataProvider(config.bridgeDynamoDbConfig);
  const clientProcessor = new ClientProcessor(config);

  const clients = await bridgeDataProvider.getActiveClients();

  for (const client of clients) {
    await clientProcessor.process(client);
  }
};

modules/config/configManager.js:

exports.getConfig = async () => {
  return { enabled: false }
}

modules/storage/bridge/bridgeDataProvider.js:

class BridgeDataProvider {
  async getActiveClients() {
    return []
  }
}
module.exports = BridgeDataProvider;

modules/domain/clientProcessor.js:

class ClientProcessor {
  async process(client) { }
}

module.exports = ClientProcessor;

index.test.js:

const { getConfig } = require('./modules/config/configManager');
const BridgeDataProvider = require('./modules/storage/bridge/bridgeDataProvider');
const ClientProcessor = require('./modules/domain/clientProcessor');

jest.mock('./modules/config/configManager');
jest.mock('./modules/storage/bridge/bridgeDataProvider');
jest.mock('./modules/domain/clientProcessor');

describe('index', () => {
  test('should pass', async () => {
    getConfig.mockResolvedValueOnce({ enabled: true, bridgeDynamoDbConfig: 'fake bridge dynamoDB config' })
    const bridgeDataProviderInstance = {
      getActiveClients: jest.fn().mockResolvedValueOnce([1, 2])
    }
    BridgeDataProvider.mockImplementation(() => bridgeDataProviderInstance);

    const clientProcessorInstance = {
      process: jest.fn()
    }
    ClientProcessor.mockImplementation(() => clientProcessorInstance)

    const { handler } = require('./');
    await handler();
    expect(getConfig).toBeCalledTimes(1);
    expect(BridgeDataProvider).toBeCalledWith('fake bridge dynamoDB config');
    expect(bridgeDataProviderInstance.getActiveClients).toBeCalledTimes(1);
    expect(clientProcessorInstance.process.mock.calls).toEqual([[1], [2]])
  })
})

Test result:

 PASS  stackoverflow/74137912/index.test.js (10.599 s)
  index
    ✓ should pass (87 ms)

---------------------------------|---------|----------|---------|---------|-------------------
File                             | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
---------------------------------|---------|----------|---------|---------|-------------------
All files                        |   76.19 |       50 |   28.57 |   78.95 |                   
 74137912                        |   92.86 |       50 |     100 |   92.31 |                   
  index.js                       |   92.86 |       50 |     100 |   92.31 | 10                
 74137912/modules/config         |   33.33 |      100 |       0 |      50 |                   
  configManager.js               |   33.33 |      100 |       0 |      50 | 2                 
 74137912/modules/domain         |      50 |      100 |       0 |      50 |                   
  clientProcessor.js             |      50 |      100 |       0 |      50 | 2                 
 74137912/modules/storage/bridge |      50 |      100 |       0 |      50 |                   
  bridgeDataProvider.js          |      50 |      100 |       0 |      50 | 3                 
---------------------------------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        11.271 s

CodePudding user response:

Thanks to @slideshowp2, he pointed me to the fact that getConfig is called at the module scope of index.js, and I need to require handler after mocking implementation of getConfig. However, I got the issue of caching the results of getConfig because getConfig is called at the top-level of the module. That makes multiple tests dependent to each other.

Finally I found a way how to do require('../index') in test ignoring the cached state of the module with a help of jest.isolateModules(fn). I added getIsolatedHandler() which returns isolated handler function without any caching state. Here's the final solution:

index.test.js

const { getConfig } = require('../modules/config/configManager');
const BridgeDataProvider = require('../modules/storage/bridge/bridgeDataProvider');
const ClientProcessor = require('../modules/domain/clientProcessor');

jest.mock('../modules/config/configManager');
jest.mock('../modules/storage/bridge/bridgeDataProvider');
jest.mock('../modules/domain/clientProcessor');

describe('index', () => {
    
    test('should pass (enabled=true)', async () => {
        getConfig.mockResolvedValueOnce({ enabled: true, bridgeDynamoDbConfig: 'fake bridge dynamoDB config' });
        const bridgeDataProviderInstance = {
            getActiveClients: jest.fn().mockResolvedValueOnce([1, 2])
        };
        BridgeDataProvider.mockImplementation(() => bridgeDataProviderInstance);

        const clientProcessorInstance = {
            process: jest.fn()
        };
        ClientProcessor.mockImplementation(() => clientProcessorInstance);

        const handler = getIsolatedHandler();
        await handler();
        expect(getConfig).toBeCalledTimes(1);
        expect(BridgeDataProvider).toBeCalledWith('fake bridge dynamoDB config');
        expect(bridgeDataProviderInstance.getActiveClients).toBeCalledTimes(1);
        expect(clientProcessorInstance.process.mock.calls).toEqual([[1], [2]]);
    });

    test('should pass (enabled=false)', async () => {
        getConfig.mockResolvedValueOnce({ enabled: false, bridgeDynamoDbConfig: 'fake bridge dynamoDB config' });
        const bridgeDataProviderInstance = {
            getActiveClients: jest.fn().mockResolvedValueOnce([1, 2])
        };
        BridgeDataProvider.mockImplementation(() => bridgeDataProviderInstance);

        const clientProcessorInstance = {
            process: jest.fn()
        };
        ClientProcessor.mockImplementation(() => clientProcessorInstance);

        const handler = getIsolatedHandler();
        await handler();
        expect(clientProcessorInstance.process).toBeCalledTimes(0);
    });
});

function getIsolatedHandler() {
    let handler;
    jest.isolateModules(() => {
        const index = require('../index');
        handler = index.handler;
    });

    return handler;
}

Test output:

PASS  test/index.test.js
  index
    √ should pass (enabled=true) (263 ms)
    √ should pass (enabled=false) (254 ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        3.289 s
  • Related