Jamadan
Jamadan

Reputation: 2313

Jest testing context / spy on mocked variables created outside of functions (class level) Postmark

I'm trying to do some testing in Jest but getting stuck with a mock/spy. I've managed to get the test working but only by changing my implementation (which I feel dirty about).

Here's the test:

import * as postmark from 'postmark';
jest.mock('postmark');

const mockGetServers = jest.fn();
const AccountClient = jest.fn(() => {
  return {
    getServers: mockGetServers
  };
});
postmark.AccountClient = AccountClient;

import accountApi from './account-api';

describe('account-api', () => {
  describe('listServers', () => {
    it('calls postmark listServers', async () => {
      await accountApi.listServers();

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

Here's the working implementation:

import * as postmark from 'postmark';
const accountToken = 'some-token-number';

const listServers = async () => {
  try {
    const accountClient = postmark.AccountClient(accountToken);
    const servers = await accountClient.getServers();
    return servers;
  } catch (e) {
    console.log('ERROR', e);
  }
};

export default {
  listServers
}

Here's the original implementation:

import * as postmark from 'postmark';
const accountToken = 'some-token-number';
const accountClient = postmark.AccountClient(accountToken);

const listServers = async () => {
  try {
    const servers = await accountClient.getServers();
    return servers;
  } catch (e) {
    console.log('ERROR', e);
  }
};

export default {
  listServers
}

The only change is where in the code the accountClient is created (either inside or outside of the listServers function). The original implementation would complete and jest would report the mock hadn't been called.

I'm stumped as to why this doesn't work to start with and guessing it's something to do with context of the mock. Am I missing something about the way jest works under the hood? As the implementation of accountApi will have more functions all using the same client it makes sense to create one for all functions rather than per function. Creating it per function doesn't sit right with me.

What is different about the way I have created the accountClient that means the mock can be spied on in the test? Is there a way I can mock (and spy on) the object that is created at class level not at function level?

Thanks

Upvotes: 1

Views: 5140

Answers (2)

Brian Adams
Brian Adams

Reputation: 45830

Am I missing something about the way jest works under the hood?

Two things to note:

  1. ES6 import calls are hoisted to the top of the current scope
  2. babel-jest hoists calls to jest.mock to the top of their code block (above everything including any ES6 import calls in the block)

What is different about the way I have created the accountClient that means the mock can be spied on in the test?

In both cases this runs first:

jest.mock('postmark');

...which will auto-mock the postmark module.

Then this runs:

import accountApi from './account-api';

In the original implementation this line runs:

const accountClient = postmark.AccountClient(accountToken);

...which captures the result of calling postmark.AccountClient and saves it in accountClient. The auto-mock of postmark will have stubbed AccountClient with a mock function that returns undefined, so accountClient will be set to undefined.

In both cases the test code now starts running which sets up the mock for postmark.AccountClient.

Then during the test this line runs:

await accountApi.listServers();

In the original implementation that call ends up running this:

const servers = await accountClient.getServers();

...which drops to the catch since accountClient is undefined, the error is logged, and the test continues until it fails on this line:

expect(mockGetServers).toHaveBeenCalledTimes(1);

...since mockGetServers was never called.

On the other hand, in the working implementation this runs:

const accountClient = postmark.AccountClient(accountToken);
const servers = await accountClient.getServers();

...and since postmark is mocked by this point it uses the mock and the test passes.


Is there a way I can mock (and spy on) the object that is created at class level not at function level?

Yes.

Because the original implementation captures the result of calling postmark.AccountClient as soon as it is imported, you just have to make sure your mock is set up before you import the original implementation.

One of the easiest ways to do that is to set up your mock with a module factory during the call to jest.mock since it gets hoisted and runs first.

Here is an updated test that works with the original implementation:

import * as postmark from 'postmark';
jest.mock('postmark', () => {  // use a module factory
  const mockGetServers = jest.fn();
  const AccountClient = jest.fn(() => {
    return {
      getServers: mockGetServers  // NOTE: this returns the same mockGetServers every time
    };
  });
  return {
    AccountClient
  }
});

import accountApi from './account-api';

describe('account-api', () => {
  describe('listServers', () => {
    it('calls postmark listServers', async () => {
      await accountApi.listServers();

      const mockGetServers = postmark.AccountClient().getServers;  // get mockGetServers
      expect(mockGetServers).toHaveBeenCalledTimes(1);  // Success!
    });
  });
});

Upvotes: 1

BadAsstronaut
BadAsstronaut

Reputation: 96

I think you might want to look at proxyquire.

import * as postmark from 'postmark';
import * as proxyquire from 'proxyquire';

jest.mock('postmark');

const mockGetServers = jest.fn();
const AccountClient = jest.fn(() => {
  return {
    getServers: mockGetServers
  };
});
postmark.AccountClient = AccountClient;

import accountApi from proxyquire('./account-api', postmark);

describe('account-api', () => {
  describe('listServers', () => {
    it('calls postmark listServers', async () => {
      await accountApi.listServers();

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

Note that I have not tested this implementation; tweaking may be required.

Upvotes: 1

Related Questions