node.jsunit-testingmockingjestjsspyon

Jest unit test to spy on lower-level method (NodeJS)


Trying to spy and override a function two levels down using Jest.

The test results say, "Expected mock function to have been called, but it was not called."

// mail/index.unit.test.js
import mail from './index';
import * as sib from '../sendinblue';

describe('EMAIL Util', () =>
  test('should call sibSubmit in server/utils/sendinblue/index.js', async() => {
    const sibMock = jest.spyOn(sib, 'sibSubmit');
    sibMock.mockImplementation(() => 'Calling sibSubmit()');
    const testMessage = {
      sender: [{ email: 'foo@example.com', name: 'Something' }],
      to: [{ email: 'foo@example.com', name: 'Something' }],
      subject: 'My Subject',
      htmlContent: 'This is test content'
    };
    await mail.send(testMessage);
    expect(sibMock).toHaveBeenCalled();
  })
);

mail.send() comes from here...

// mail/index.js
import { sibSendTransactionalEmail } from '../sendinblue';

export default {
  send: async message => {
    try {
      return await sibSendTransactionalEmail(message);
    } catch(err) {
      console.error(err);
    }
  }
};

Which uses SendInBlue's API via axios (why I need to mock)...

// sendinblue/index.js
import axios from 'axios';
import config from '../../config/environment';

export async function sibSubmit(method, url, data) {
  let instance = axios.create({
    baseURL: 'https://api.sendinblue.com',
    headers: { 'api-key': config.mail.apiKey }
  });
  try {
    const response = await instance({
      method,
      url,
      data
    });
    return response;
  } catch(err) {
    console.error('Error communicating with SendInBlue', instance, err);
  }
}

export const sibSendTransactionalEmail = message => sibSubmit('POST', '/v3/smtp/email', message);

I assumed mail.send() would call sibSendTransactionalEmail() in the other module and it would call sibSubmit(), the focus of jest.spyOn(). Wondering where I went wrong.


Solution

  • jest.spyOn replaces the method on the object it is passed with a spy.

    In this case you are passing sib which represents the ES6 module exports from sendinblue.js, so Jest will replace the module export for sibSubmit with the spy and give the spy the mock implementation you provided.

    mail.send then calls sibSendTransactionalEmail which then calls sibSubmit directly.

    In other words, your spy is not called because sibSendTransactionalEmail does not call the module export for sibSubmit, it is just calling sibSubmit directly.

    An easy way to resolve this is to note that "ES6 modules support cyclic dependencies automatically" so you can simply import the module into itself and call sibSubmit from within sibSendTransactionalEmail using the module export:

    import axios from 'axios';
    import config from '../../config/environment';
    import * as sib from './';  // import module into itself
    
    export async function sibSubmit(method, url, data) {
      let instance = axios.create({
        baseURL: 'https://api.sendinblue.com',
        headers: { 'api-key': config.mail.apiKey }
      });
      try {
        const response = await instance({
          method,
          url,
          data
        });
        return response;
      } catch(err) {
        console.error('Error communicating with SendInBlue', instance, err);
      }
    }
    
    export const sibSendTransactionalEmail = message => sib.sibSubmit('POST', '/v3/smtp/email', message);  // call sibSubmit using the module export
    

    Note that replacing ES6 module exports with jest.spyOn like this works because Jest transpiles the ES6 modules to Node modules in a way that allows them to be mutated