Making your life less miserable when mocking AWS SDK with Node
You're writing your tests, your AI agent is crushing it, all use cases and edge ones are being covered, but you've installed an AWS SDK and now you'll need to mock it (oh boy). After a few hours in, and a few years of life out the window, you notice that nothing you do can actually mock the cretin and you begin to lose hope, but after doing some research you soon notice that you'll need another dependency just for that: aws-sdk-client-mock. It surely gives you a convenient way to mock the clients, but it also makes them require special treatment, since it enforces a specific way of mocking. The good thing is, it does not need to be like this.
The traditional way
The usual approach with this library is to create a mock for the client class, implement the responses and reset it before each test.
import 'aws-sdk-client-mock-jest/vitest';
import {mockClient} from 'aws-sdk-client-mock';
import {PublishCommand, SNSClient} from '@aws-sdk/client-sns';
import {handler} from '../src';
const snsMock = mockClient(SNSClient);
/**
* To be sure that unit tests are independent from each other,
* reset mock behavior between the tests.
*/
beforeEach(() => {
snsMock.reset();
});
it('message IDs are returned', async () => {
snsMock.on(PublishCommand).resolves({
MessageId: '12345678-1111-2222-3333-111122223333',
});
const result = await handler({
messages: ['one', 'two', 'three']
});
expect(result).toHaveLength(3);
expect(result[0]).toBe('12345678-1111-2222-3333-111122223333');
});
it('SNS Client is called with PublishCommand', async () => {
snsMock.on(PublishCommand).resolves({
MessageId: '111-222-333',
});
await handler({
messages: ['qq', 'xx']
});
expect(snsMock).toHaveReceivedCommandTimes(PublishCommand, 2);
});
It is compact and has a useful command-oriented API. You can match a specific input, return a response and move on with your life. For a small project, or one which already standardizes on this package, it sounds great, right?!
That works until you meet a project where HTTP calls are controlled by request interceptors and dependencies are already being mocked (or spied) with the project's test framework. Now your new side quest is to maintain AWS clients with its own mock object, its own reset method and, if you want to make assertions on them, you'll need to install extra matchers just for that.
Beginning your journey to find inner peace
A v3 client receives commands through send. So the
smallest useful fake client is a constructor which returns an object with
that method, with the method itself being a regular mock.
From now on, the examples you'll see will use Vitest and Cognito SDK to guide you through your journey.
export const cognitoIdentityProviderClientSendMethodMock = vi.fn();
export const cognitoIdentityProviderClientMock = vi.fn(
class {
constructor() {
return {
send: cognitoIdentityProviderClientSendMethodMock,
};
}
},
);
There is no magic here. The constructor is a mock, the method is a mock and every assertion remains available. They'll be used below to mock the actual client.
I've chosen to export it from a module, to keep things organized, but suit yourself to put it anywhere, just make sure you don't end up creating a landfill (please).
Replacing the real client
A shared test setup replaces only the client constructor and preserves every other export from the SDK. Commands and types continue to be the real ones.
import { cognitoIdentityProviderClientMock } from "./tests/cognito-mocks";
vi.mock(
"@aws-sdk/client-cognito-identity-provider",
async (importOriginal) => ({
...(await importOriginal()),
CognitoIdentityProviderClient: cognitoIdentityProviderClientMock,
}),
);
This matters! Replacing the whole module with a handwritten object is
tempting, but then the test must pretend to be the AWS package. Keeping the
original exports means
CreateUserPoolClientCommand is still a genuine command
and the mock only swaps the part which would otherwise try to talk to the
internet.
If the project creates clients at module scope, load this setup before
importing the application modules. Vitest hoists
vi.mock calls, but it cannot travel back in time and
replace a client which was constructed before the mock setup ran. Putting
the setup file in Vitest's setupFiles is the least
exciting solution, which is precisely what you want from test
infrastructure.
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
setupFiles: ["./my-client-mock.ts"],
// Everything else...
},
});
Give the mock a vocabulary
Calling mockResolvedValueOnce directly in every test
works, but, if you don't feel like repeating yourself every single time,
creating a small interceptor gives the dependency a vocabulary and keeps the
default fixture in one place.
import type { CreateUserPoolClientCommandOutput } from "@aws-sdk/client-cognito-identity-provider";
import { cognitoCreateUserPoolClientResponse } from "./cognito-fixtures.mjs";
import { cognitoIdentityProviderClientSendMethodMock } from "./tests/cognito-mocks"
export const cognitoCreateUserPoolClientInterceptors = {
success: (
props: {
response?: Partial<CreateUserPoolClientCommandOutput>;
} = {},
) => {
const { response = {} } = props;
cognitoIdentityProviderClientSendMethodMock.mockResolvedValueOnce({
...cognitoCreateUserPoolClientResponse(), // This one here is a default successful response being returned
...response,
});
return cognitoCreateUserPoolClientInterceptors;
},
error: (error: Error = new Error("Cognito had other plans")) => {
cognitoIdentityProviderClientSendMethodMock.mockRejectedValueOnce(error);
return cognitoCreateUserPoolClientInterceptors;
},
};
The default fixtures make the happy (or not) paths one line, the partial override keeps unusual cases small and returning the interceptor object lets you chain more complicated scenarios without making your eyes bleed from so much text.
cognitoCreateUserPoolClientInterceptors
.success()
.success({
response: {
UserPoolClient: {
ClientId: "second-client",
},
},
});
Testing the result and the command
With the responses prepared, the test reads like the rest of a test suite.
Assert the returned value, then inspect the command passed to
send.
import { CreateUserPoolClientCommand } from "@aws-sdk/client-cognito-identity-provider";
import { cognitoIdentityProviderClientSendMethodMock } from "./tests/cognito-mocks"
test("creates a client", async () => {
const userPoolId = "biri";
const clientName = "din";
cognitoCreateUserPoolClientInterceptors.success();
expect(
await createUserPoolClientFunction(userPoolId, clientName)
).toEqual({
UserPoolClient: {
ClientId: "abili",
ClientSecret: "dibob",
},
});
expect(
cognitoIdentityProviderClientSendMethodMock,
).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
input: {
UserPoolId: userPoolId,
ClientName: clientName,
AllowedOAuthFlows: ["client_credentials"],
EnableTokenRevocation: false,
ExplicitAuthFlows: ["ALLOW_REFRESH_TOKEN_AUTH"],
GenerateSecret: true,
SupportedIdentityProviders: ["COGNITO"],
AllowedOAuthScopes: [],
AllowedOAuthFlowsUserPoolClient: true,
},
}),
);
expect(
cognitoIdentityProviderClientSendMethodMock,
).toHaveBeenCalledExactlyOnceWith(
expect.any(CreateUserPoolClientCommand),
);
});
The two assertions answer different questions. The first one checks relevant
configuration that would be sent to AWS. The second one checks that the
repository used the correct command class. You could inspect
mock.calls yourself, but using Vitest's asymmetric
matchers keeps the test focused on the contract instead of the internal
shape of a spy.
Resetting without creating another kind of misery
If you use mockResolvedValue instead of the one-shot
version, add an explicit call to it.
beforeEach(() => {
cognitoIdentityProviderClientSendMethodMock.mockReset(); // mockClear() or mockRestore() as well, depending on your needs.
});
Sprinkling mocks cleanup calls through individual test files is how you end
up with a test which passes alone, fails in the suite and consumes the rest
of your sanity (not the right path for inner peace). So, if possible,
configure tests to be reset, restored or cleared in the settings. Vitest's
clearMocks, mockReset and
restoreMocks settings handle all you need to keep your
tests free of bugs.
Reuse this pattern with other clients
The same setup can replace SESClient,
SSMClient or any other v3 client. Give each service its
own send mock, fake constructor, fixtures and behavior helpers. Do not reuse
one global AWS send mock unless you enjoy discovering which service consumed
the response intended for another one.
Should you still use aws-sdk-client-mock?
Wrap-up
By now you should've already found inner peace, but to keep it going when
mocking the AWS SDK, remember: replace the client constructor, keep a normal
Vitest mock behind send and wrap common responses in
small service-level interceptors. The real commands stay real, the tests
keep their usual vocabulary and nobody has to remember a new mocking
religion just because a repository imports something from AWS. See you!