How to mock and spy on AWS-SDK calls with jest
February 20, 2022
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}
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
})
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})
}))
})
})
})
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)
}))
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.