Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Spying with jest.spyOn() example? #127

Closed
denchen opened this issue May 10, 2018 · 10 comments
Closed

Spying with jest.spyOn() example? #127

denchen opened this issue May 10, 2018 · 10 comments

Comments

@denchen
Copy link

denchen commented May 10, 2018

Can I get an example of using this library with Jest's .spyOn()? I basically want to make sure certain AWS functions get called with the proper arguments (via Jest's .toHaveBeenCalledTimes() & .toHaveBeenCalledWith()).

@denchen
Copy link
Author

denchen commented May 10, 2018

I'm gonna answer my own question (I solved it almost immediately after I posted it here), so hopefully someone else can make use of it. It's a roundabout way of achieving what I wanted with jest.spyon():

    it('should putObject', async () => {
      const putObjectMock = jest.fn(() => "your response here");
      awsMock.mock('S3', 'putObject', (params, callback) => {
        callback(null, putObjectMock(params));
      });

      const data = await functionBeingTested();
      expect(putObjectMock).toHaveBeenCalledTimes(1);
      expect(putObjectMock).toHaveBeenCalledWidth(/* your arguments here */);
    });

@frctnlss
Copy link

frctnlss commented Oct 4, 2019

So I felt that this left some room for clarification as I was not so sure how they actually implemented as awsMock was not defined in the example above. The other issue is that this does not explain how to achieve the same result with a module that is an alias to another like DocumentClient -> DynamoDB. The example below also shows how you might do it by using the callback.

//file-to-test.js
const AWS = require('aws-sdk');

export const getData = async () => {
  const dynamoDb = new AWS.DynamoDB.DocumentClient({ region: 'us-east-1' });
  const params = {//your parameters}
  return await new Promise((resolve, reject) => {
    dynamoDb.batchGet(params, (err, data) => {
      if (err) reject(err);
      resolve(data);
    });
  });
};
//test-file.js
import { getData } from './file-to-test';

const AWS = require('aws-sdk');

test('...', async () => {
  const awsMock = jest.spyOn(AWS.DynamoDB, 'DocumentClient');
  awsMock.mockImplementation(() => {
    return {
      batchGet: async (params, callback) => {
        callback(//err object or null, //data object or null);
      }
    }
  });
  const data = await getData();
  expect(awsMock).toHaveBeenCalledTimes(1);
  expect(awsMock).toHaveBeenCalledWith({ region: 'us-east-1' });
  expect(data).toBe(//err or data object given to implementation);
});

You can follow the same principle if you use the promise() method as well if you mock batchGet to not be an async function and have it return this while storing the result in a variable on the object and finally defining an async function promise that will only return data or throw an error as it does in the actual library. If you want to also check that (using this example) batchGet is called once with certain parameters, then you can do as the original answer above did and create a function from jest.fn() making the defined function asynchronous and setting batchGet to the newly created mock function and assert all the things you want.

I hope this was helpful as a lot of the solutions out there either break encapsulation or do not give examples of this specific situation where you might be working with a callback function or aliased class declaration.

@daoz1026
Copy link

daoz1026 commented Feb 24, 2020

I'm gonna answer my own question (I solved it almost immediately after I posted it here), so hopefully someone else can make use of it. It's a roundabout way of achieving what I wanted with jest.spyon():

    it('should putObject', async () => {
      const putObjectMock = jest.fn(() => "your response here");
      awsMock.mock('S3', 'putObject', (params, callback) => {
        callback(null, putObjectMock(params));
      });

      const data = await functionBeingTested();
      expect(putObjectMock).toHaveBeenCalledTimes(1);
      expect(putObjectMock).toHaveBeenCalledWidth(/* your arguments here */);
    });

Thank you very much, it worked for me, but I had an error with putObjectMock(params), fixed it with

const putObjectMock = jest.fn((**params**) => "your response here");

Hope it helps someone :D

@pavian
Copy link

pavian commented Sep 14, 2020

So I felt that this left some room for clarification as I was not so sure how they actually implemented as awsMock was not defined in the example above. The other issue is that this does not explain how to achieve the same result with a module that is an alias to another like DocumentClient -> DynamoDB. The example below also shows how you might do it by using the callback.

//file-to-test.js
const AWS = require('aws-sdk');

export const getData = async () => {
  const dynamoDb = new AWS.DynamoDB.DocumentClient({ region: 'us-east-1' });
  const params = {//your parameters}
  return await new Promise((resolve, reject) => {
    dynamoDb.batchGet(params, (err, data) => {
      if (err) reject(err);
      resolve(data);
    });
  });
};
//test-file.js
import { getData } from './file-to-test';

const AWS = require('aws-sdk');

test('...', async () => {
  const awsMock = jest.spyOn(AWS.DynamoDB, 'DocumentClient');
  awsMock.mockImplementation(() => {
    return {
      batchGet: async (params, callback) => {
        callback(//err object or null, //data object or null);
      }
    }
  });
  const data = await getData();
  expect(awsMock).toHaveBeenCalledTimes(1);
  expect(awsMock).toHaveBeenCalledWith({ region: 'us-east-1' });
  expect(data).toBe(//err or data object given to implementation);
});

You can follow the same principle if you use the promise() method as well if you mock batchGet to not be an async function and have it return this while storing the result in a variable on the object and finally defining an async function promise that will only return data or throw an error as it does in the actual library. If you want to also check that (using this example) batchGet is called once with certain parameters, then you can do as the original answer above did and create a function from jest.fn() making the defined function asynchronous and setting batchGet to the newly created mock function and assert all the things you want.

I hope this was helpful as a lot of the solutions out there either break encapsulation or do not give examples of this specific situation where you might be working with a callback function or aliased class declaration.

Trying to spy on an SSM api function this way. Getting Error: Cannot spy the describeInstanceInformation property because it is not a function;

Can you help?

@frctnlss
Copy link

@pavian So I actually experience this problem as well just recently. I forgot that I had done this and reimplemented and resolved the problem in a different way. I do want to add the caveat that I use typescript and have adapted the code to native javascript in the first example. This example is going to keep typescript in place. The reason is to try to help more people.

import { EC2 } from 'aws-sdk';

export const getSecurityGroups = async (): string[] => {
  const ec2 = new EC2({apiVersion: '2016-11-15'});
  const { SecurityGroups } = await ec2.describeSecurityGroups().promise();
  return SecurityGroups.reduce((agg, current) => {
    agg.push(current.GroupId);
    return agg;
  }, []);
}
// Mock the library first before the import. Order of operations does matter as otherwise the library does not get mocked.
jest.mock('aws-sdk');

import { handler } from '../../../src/handlers/InsecureSecurityGroup';
// Typescript only import
import { DescribeSecurityGroupsResult } from 'aws-sdk/clients/ec2';
import * as aws from 'aws-sdk';

// The aws variable is not associated with the mocking library. 
// This tells typescript and your ide to attach the mocking methods into all objects of the module
// Skip this for native javascript
const mocked = aws as jest.Mocked<typeof aws>;

test('...', async () => {
  const sgList: DescribeSecurityGroupsResult = {
    SecurityGroups: [
      {
        GroupId: 'id'
      },
      {
        GroupId: 'another id'
      }
    ]
  };

  const sgListResolve = jest.fn().mockResolvedValueOnce(sgList);
  const EC2Promise = jest.fn().mockReturnValueOnce({
    promise: sgListResolve
  })

  // ignored because we are not mocking the entire EC2 object
  // @ts-ignore
  mocked.EC2.mockImplementation(() => ({
    describeSecurityGroups: EC2Promise
  }));

  const response = await handler();
  expect(sgListResolve).toHaveBeenCalled();
  expect(sgListResolve).toHaveBeenCalledTimes(1);
  expect(EC2Promise).toHaveBeenCalled();
  expect(EC2Promise).toHaveBeenCalledTimes(1);
  expect(response).toEqual(['id','another id' ]);
});

If you want to read the config data sent to the EC2 constructor method, then all you would do is set it to a jest.fn() definition which would return the object with the functions being tested as currently done. I personally prefer the mock implementation over the spy for handling constructor mocking. I feel that it looks more explicit. Another thing to note is that you are going to want to not clear mocks in your jest config with this method. It still works but the entire library is mocked on each test case and exponentially increases test time.

@pavian
Copy link

pavian commented Sep 14, 2020

@pavian So I actually experience this problem as well just recently. I forgot that I had done this and reimplemented and resolved the problem in a different way. I do want to add the caveat that I use typescript and have adapted the code to native javascript in the first example. This example is going to keep typescript in place. The reason is to try to help more people.

import { EC2 } from 'aws-sdk';

export const getSecurityGroups = async (): string[] => {
  const ec2 = new EC2({apiVersion: '2016-11-15'});
  const { SecurityGroups } = await ec2.describeSecurityGroups().promise();
  return SecurityGroups.reduce((agg, current) => {
    agg.push(current.GroupId);
    return agg;
  }, []);
}
// Mock the library first before the import. Order of operations does matter as otherwise the library does not get mocked.
jest.mock('aws-sdk');

import { handler } from '../../../src/handlers/InsecureSecurityGroup';
// Typescript only import
import { DescribeSecurityGroupsResult } from 'aws-sdk/clients/ec2';
import * as aws from 'aws-sdk';

// The aws variable is not associated with the mocking library. 
// This tells typescript and your ide to attach the mocking methods into all objects of the module
// Skip this for native javascript
const mocked = aws as jest.Mocked<typeof aws>;

test('...', async () => {
  const sgList: DescribeSecurityGroupsResult = {
    SecurityGroups: [
      {
        GroupId: 'id'
      },
      {
        GroupId: 'another id'
      }
    ]
  };

  const sgListResolve = jest.fn().mockResolvedValueOnce(sgList);
  const EC2Promise = jest.fn().mockReturnValueOnce({
    promise: sgListResolve
  })

  // ignored because we are not mocking the entire EC2 object
  // @ts-ignore
  mocked.EC2.mockImplementation(() => ({
    describeSecurityGroups: EC2Promise
  }));

  const response = await handler();
  expect(sgListResolve).toHaveBeenCalled();
  expect(sgListResolve).toHaveBeenCalledTimes(1);
  expect(EC2Promise).toHaveBeenCalled();
  expect(EC2Promise).toHaveBeenCalledTimes(1);
  expect(response).toEqual(['id','another id' ]);
});

If you want to read the config data sent to the EC2 constructor method, then all you would do is set it to a jest.fn() definition which would return the object with the functions being tested as currently done. I personally prefer the mock implementation over the spy for handling constructor mocking. I feel that it looks more explicit. Another thing to note is that you are going to want to not clear mocks in your jest config with this method. It still works but the entire library is mocked on each test case and exponentially increases test time.

Thank you, but I am afraid I am missing the spying part. All expects fail to count the function calls.
Here is my sample:
test('Should only call describeInstanceInformation once', async () => {
const sgListResolve = jest.fn().mockResolvedValueOnce(require('./samples/instanceListObject.json'));
const InstanceListPromise = jest.fn().mockReturnValueOnce({
promise: sgListResolve
})
// @ts-ignore
mocked.SSM.mockImplementation(() => ({
describeInstanceInformation: InstanceListPromise
}));
await listSSMInstances.call( "");
expect(sgListResolve).toBeCalledTimes(1);
expect(InstanceListPromise).toHaveBeenCalledTimes(1);
});

@frctnlss
Copy link

@pavian can you list what is happening in the .call() method please

@frctnlss
Copy link

@pavian try removing the .call("") method and rerun your test.

@pavian
Copy link

pavian commented Sep 14, 2020

@pavian try removing the .call("") method and rerun your test.

I've figured it out. Just moved the
const ssm = new SSM();
into the wrapper function

@frctnlss
Copy link

frctnlss commented Sep 14, 2020

If you had the ssm object instantiated outside the function call for performance reasons, then you can either cache outside the method or create a singleton and mock the singleton instead.

cache

let ssm?: SSM = undefined;

export const listSSMInstances = (): Promise<SSM.DescribeInstanceInformationResult> => {
  if (!!ssm) {
    ssm = new SSM();
  }
  return ssm.describeInstanceInformation({
    Filters: [{
        Key: "ResourceType",
        Values: ["EC2Instance"]
      }, {
        Key: "PlatformTypes",
        Values: ["Windows"]
    }]}).promise();
}

For singleton's reference this article https://derickbailey.com/2016/03/09/creating-a-true-singleton-in-node-js-with-es6-symbols/

@denchen denchen closed this as completed Sep 20, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants