How to Mock AWS SDK v3 in Vitest Without Extra Dependencies

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!

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.