DEV Community

Jan Grzesik
Jan Grzesik

Posted on

How to mock and spy on AWS-SDK calls with jest

Jest has great built-ins for mocking, but mocking aws-sdk properly is quite tricky 😅.
I wanted to write unit tests that work without performing network calls to AWS.
I needed to both mock (change the behavior) and spy (assert on the changed methods) the SDK methods.

At first, I tried a naive approach...

ReferenceError: Cannot access 'iotInstance' before initialization

The module factory of jest.mock() is not allowed to reference any out-of-scope variables.
Invalid variable access: iotInstance

...and ended up with mysterious errors like this ☝️.

I googled for solutions, but the ones that worked, were just mocks,
without a way to spy.
I wanted tame this beast, because we use AWS SDK extensively. I decided to dig a little deeper.

Let's start with the code that we want to test

// SocketService.ts 
const config = require('config')
const AWS = require('aws-sdk')

// This is the AWS SDK part that we want to mock
const iotDataInstance = new AWS.IotData({
  endpoint: config.aws.iotEndpointHost,
  region: config.aws.iotAwsRegion,
  maxRetries: 0
})

class SocketService {
  static async publishNewVersion(projectId: string, version: string) {
    const params = {
      topic: `projects/${projectId}/versions`,
      payload: JSON.stringify({version}),
      qos: 0
    }

    // This is the part that we want to spy on
    await iotDataInstance
        .publish(params)
        .promise()
  }
}

module.exports = {SocketService}
Enter fullscreen mode Exit fullscreen mode

It's a simplified version of one of many similar modules that I encounter in my day-job. This is part that causes problems:

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

// Side-effect with no clean way to control it from outside 
// We need to mock this!
const iotDataInstance = new AWS.IotData({
  endpoint: config.aws.iotEndpointHost,
  region: config.aws.iotAwsRegion,
  maxRetries: 0
})
Enter fullscreen mode Exit fullscreen mode

Similar side-effects make testing difficult (and may lead to unexpected results).
This is an anti-pattern, but it's a very common in the Node.js realm, and I wanted to learn how to deal with it.

Idea: Using a dependency-injection container would solve this problem.

Another idea: We could also extract the AWS service instance to a "demilitarized zone"

Final Solution, the test suite:

// SocketService.spec.ts 

// No need to import aws-sdk in the test file, we will mock it!

// ⚠️ Mock instance needs to be initialized before the module-in-test is required, 
// otherwise will get this error: 
// "ReferenceError: Cannot access 'mockIotDataInstance' before initialization"
//
// ⚠️ Variable name is ALSO IMPORTANT! It has to start with 'mock', 
// otherwise we will get this error: 
// "ReferenceError (...)
// The module factory of `jest.mock()` is not allowed to reference any out-of-scope variables.
// Invalid variable access: notMockIotDataInstance
// "
const mockIotDataInstance = {
  // Tip: you can use `mockReturnThis` with fluid API
  publish: jest.fn().mockReturnThis(), 
  promise: jest.fn().mockResolvedValue({})
}

// ⚠️ Importing the module-in-test needs to be placed AFTER 
// we initialize the mockInstance,
// We can also import the module after the jest.mock('aws-sdk', ...) call, 
// it doesn't matter
const {SocketService} = require('./SocketService')

// Here we inject the mock into the module
jest.mock('aws-sdk', () => ({
  // ⚠️ IotData cannot be an arrow function! must be either 
  // a function or a jest.fn. 
  // Otherwise we will get this error:
  // "TypeError: Aws.IotData is not a constructor"
  IotData: jest.fn(
      // Implementation can be anything (arrow, function, jest.fn)
      () => mockIotDataInstance
  )
}))

describe('SocketService', () => {
  beforeEach(() => {
    // ⚠️ Important: we cannot call "resetAllMocks" because it will 
    // reset the mocks inside mockIotDataInstance
    // For example the .promise() call would not work with
    jest.clearAllMocks()
  })

  afterAll(() => {
    // Remember to cleanup the mocks afterwards
    jest.restoreAllMocks()
  })

  describe('publishNewVersion', () => {
    test('publishes a message to project versions channel', async () => {
      const projectId = 'my-project-id'
      const myVersion = Math.random()
          .toFixed(8)
          .slice(2)

      await SocketService.publishNewVersion(projectId, myVersion)

      expect(mockIotDataInstance.publish).toHaveBeenCalledWith(
          expect.objectContaining({
            topic: `projects/${projectId}/versions`,
            payload: JSON.stringify({version: myVersion})
          }))
    })
  })
})
Enter fullscreen mode Exit fullscreen mode

To make assertions in tests cases we need a mock IoTData instance (mockIotDataInstance in the code).

Its critical that variable name that starts with mock
so that jest gives it a special treatment
and allows to reference them in the hoisted calls to jest.mock('aws-sdk', ...) 😱

// Initialize the mock instance before importing 
// the module-in-test (the mock instance will be used in the the side-effect)
const mockIotDataInstance = {
  publish: jest.fn().mockReturnThis(),
  promise: jest.fn().mockResolvedValue({})
}

// Import module-in-test 
const {SocketService} = require('./SocketService')

// Setup the mock
jest.mock('aws-sdk', () => ({
  IotData: jest.fn(() => mockIotDataInstance)
}))
Enter fullscreen mode Exit fullscreen mode

Alternatives

You can use a dedicated module that makes mocking easier like aws-sdk-mock.
I just prefer to use as little dependencies as possible, especially in bigger projects.

Thanks for reading 😇!

Originally posted on https://blog.jangrzesik.com/mocking-aws-sdk-with-jest/

Discussion (0)