Mock AWS SDK v3 Clients in Unit Tests (Jest and Vitest)

Rows of clean laboratory test tubes and glass flasks on a metal rack

Photo by Romina Mosquera on Unsplash

To mock AWS SDK v3 in Jest, install aws-sdk-client-mock, call mockClient(S3Client) (or any client class) and declare behavior per command with .on(GetObjectCommand).resolves({...}), .rejects(...) or .callsFake(...). Call reset() in beforeEach, and assert calls with the aws-sdk-client-mock-jest matchers such as toHaveReceivedCommandWith. The same mocks work in Vitest.

Code that talks to AWS needs tests that don’t. You want to check that a handler builds the right PutCommand, maps a ConditionalCheckFailedException to a friendly result, follows every page of a listing and reads an S3 body correctly, without credentials, network calls or a shared test account.

This guide is for TypeScript developers on AWS SDK for JavaScript v3. You’ll get a small module and a complete test file that mock AWS SDK v3 clients in Jest, the same tests in Vitest, a reference for the behaviors and matchers you’ll use most, and the errors that trip people up. Every test in this guide was type-checked with strict tsc and run against the real packages in September 2026.

Why use aws-sdk-client-mock instead of jest.mock?

In SDK v3, every call goes through client.send(command). aws-sdk-client-mock replaces send on the client class (or one instance) with a Sinon stub and lets you declare responses per command type and input. The aws-sdk-client-mock package on npm describes it as recommended by the AWS SDK for JavaScript team.

  • Typed. .on(GetCommand).resolves(...) only accepts a valid GetCommand output, so a typo in a mock fails at compile time.
  • No module surgery. jest.mock("@aws-sdk/client-s3") replaces every export, including the command classes your code constructs, and has to be rebuilt by hand for each test.
  • Framework-agnostic. The stubs work in Jest, Vitest and Mocha; only the optional matchers are framework-specific.

If you’re moving tests from v2’s aws-sdk-mock, the step-by-step guide to migrate a Node.js app from AWS SDK v2 to v3 covers the client changes that make the old mocks obsolete, and the free AWS SDK JavaScript v2 to v3 converter gives you a first draft of the code under test to review.

Prerequisites

  • Node.js 18 or later, TypeScript, and the SDK v3 clients your code uses. This guide uses @aws-sdk/client-dynamodb, @aws-sdk/lib-dynamodb and @aws-sdk/client-s3.
  • aws-sdk-client-mock and aws-sdk-client-mock-jest (version 4.1.0 as of September 2026) and @smithy/util-stream for S3 bodies. Version 3 or later of the mock library works with @aws-sdk/* 3.363.0 and later.
  • For Jest: jest and a TypeScript transform. The example uses @swc/jest, which doesn’t depend on the TypeScript compiler version; ts-jest 29.4 declares support for TypeScript below 7. For Vitest: vitest, which runs TypeScript without a transform.
  • No AWS credentials. Mocked tests never sign or send a request, so they run in CI with no profile or Region set. In production the same module resolves credentials as described in AWS SDK v3 credential providers: fromIni, fromSSO and assume role.

How to mock AWS SDK v3 in Jest, step by step

  1. Mock the class your code sends throughmockClient(DynamoDBDocumentClient) for lib-dynamodb commands, mockClient(S3Client) for S3. Mocking the class covers instances your module created at import time.
  2. Reset before each testreset() clears behaviors and recorded calls. resetHistory() clears only the calls; restore() removes the mock.
  3. Declare behavior per test.on(Command) matches any input; .on(Command, partialInput) matches inputs containing those fields. Declare the wider matcher first: a later, wider one overrides an earlier, narrower one.
  4. Return what AWS would.resolves(), .resolvesOnce() for consecutive calls, .rejects() for errors and .callsFake() to compute a response from the input.
  5. Assert the callsImport aws-sdk-client-mock-jest once and use toHaveReceivedCommandWith, toHaveReceivedCommandTimes or toHaveReceivedNthCommandWith.

Example: the code under test

A small orders module with four behaviors worth testing: a DocumentClient read, a conditional write, a paginated S3 listing and an S3 body read. It builds the same commands as the guides to update DynamoDB items with conditions in AWS SDK v3 and list all objects in an S3 bucket with AWS SDK v3.

src/orders.ts

// src/orders.ts: the code under test
import { ConditionalCheckFailedException, DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand, PutCommand } from "@aws-sdk/lib-dynamodb";
import { GetObjectCommand, S3Client, paginateListObjectsV2 } from "@aws-sdk/client-s3";

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const s3 = new S3Client({});
const TABLE = process.env.ORDERS_TABLE ?? "Orders";

export interface Order {
  orderId: string;
  customerId: string;
  total: number;
}

export async function getOrder(orderId: string): Promise<Order | undefined> {
  const { Item } = await ddb.send(new GetCommand({ TableName: TABLE, Key: { orderId } }));
  return Item as Order | undefined;
}

/** Creates the order once; a second call with the same ID returns "exists" instead of overwriting. */
export async function createOrder(order: Order): Promise<"created" | "exists"> {
  try {
    await ddb.send(new PutCommand({
      TableName: TABLE,
      Item: order,
      ConditionExpression: "attribute_not_exists(orderId)",
    }));
    return "created";
  } catch (err) {
    if (err instanceof ConditionalCheckFailedException) return "exists";
    throw err;
  }
}

/** Counts invoice objects under a prefix, across every page of ListObjectsV2. */
export async function countInvoices(bucket: string, prefix: string): Promise<number> {
  let count = 0;
  for await (const page of paginateListObjectsV2({ client: s3 }, { Bucket: bucket, Prefix: prefix })) {
    count += page.Contents?.length ?? 0;
  }
  return count;
}

export async function readInvoice(bucket: string, key: string): Promise<string> {
  const { Body } = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
  if (!Body) throw new Error(`s3://${bucket}/${key} has no body`);
  return Body.transformToString();
}

Example: mock AWS SDK v3 clients in Jest

test/orders.test.ts

// test/orders.test.ts: Jest
import { Readable } from "node:stream";
import { beforeEach, describe, expect, it } from "@jest/globals";
import { mockClient } from "aws-sdk-client-mock";
import "aws-sdk-client-mock-jest";
import { ConditionalCheckFailedException } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand, PutCommand } from "@aws-sdk/lib-dynamodb";
import { GetObjectCommand, ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3";
import { sdkStreamMixin } from "@smithy/util-stream";
import { countInvoices, createOrder, getOrder, readInvoice } from "../src/orders";

const ddbMock = mockClient(DynamoDBDocumentClient);
const s3Mock = mockClient(S3Client);

beforeEach(() => {
  ddbMock.reset(); // clears behaviors and recorded calls
  s3Mock.reset();
});

describe("getOrder", () => {
  it("returns the item for the key it asked for", async () => {
    ddbMock.on(GetCommand, { Key: { orderId: "O-1" } }).resolves({
      Item: { orderId: "O-1", customerId: "C-9", total: 42.5 },
    });

    await expect(getOrder("O-1")).resolves.toEqual({ orderId: "O-1", customerId: "C-9", total: 42.5 });
    expect(ddbMock).toHaveReceivedCommandWith(GetCommand, { TableName: "Orders", Key: { orderId: "O-1" } });
  });

  it("returns undefined when the item doesn't exist", async () => {
    ddbMock.on(GetCommand).resolves({});
    await expect(getOrder("missing")).resolves.toBeUndefined();
  });
});

describe("createOrder", () => {
  const order = { orderId: "O-2", customerId: "C-1", total: 10 };

  it("writes with a condition so it never overwrites", async () => {
    ddbMock.on(PutCommand).resolves({});
    await expect(createOrder(order)).resolves.toBe("created");
    expect(ddbMock).toHaveReceivedCommandTimes(PutCommand, 1);
    expect(ddbMock).toHaveReceivedCommandWith(PutCommand, {
      ConditionExpression: "attribute_not_exists(orderId)",
    });
  });

  it("maps ConditionalCheckFailedException to 'exists'", async () => {
    ddbMock.on(PutCommand).rejects(
      new ConditionalCheckFailedException({ message: "The conditional request failed", $metadata: { httpStatusCode: 400 } }),
    );
    await expect(createOrder(order)).resolves.toBe("exists");
  });

  it("rethrows other errors", async () => {
    const throttled = new Error("Rate exceeded");
    throttled.name = "ProvisionedThroughputExceededException";
    ddbMock.on(PutCommand).rejects(throttled);
    await expect(createOrder(order)).rejects.toThrow("Rate exceeded");
  });
});

describe("countInvoices", () => {
  it("follows every page of the paginator", async () => {
    s3Mock
      .on(ListObjectsV2Command)
      .resolvesOnce({ Contents: [{ Key: "inv/1.pdf" }, { Key: "inv/2.pdf" }], IsTruncated: true, NextContinuationToken: "t1" })
      .resolvesOnce({ Contents: [{ Key: "inv/3.pdf" }], IsTruncated: false });

    await expect(countInvoices("billing", "inv/")).resolves.toBe(3);
    expect(s3Mock).toHaveReceivedCommandTimes(ListObjectsV2Command, 2);
    expect(s3Mock).toHaveReceivedNthCommandWith(2, ListObjectsV2Command, { ContinuationToken: "t1" });
  });
});

describe("readInvoice", () => {
  it("reads the object body as a string", async () => {
    const body = sdkStreamMixin(Readable.from([Buffer.from("invoice #7")]));
    s3Mock.on(GetObjectCommand).resolves({ Body: body });
    await expect(readInvoice("billing", "inv/7.txt")).resolves.toBe("invoice #7");
  });

  it("uses callsFake to answer based on the input", async () => {
    s3Mock.on(GetObjectCommand).callsFake((input: { Key?: string }) => {
      if (input.Key === "inv/404.txt") throw Object.assign(new Error("The specified key does not exist."), { name: "NoSuchKey" });
      return { Body: sdkStreamMixin(Readable.from([Buffer.from(`body of ${input.Key}`)])) };
    });
    await expect(readInvoice("billing", "inv/1.txt")).resolves.toBe("body of inv/1.txt");
    await expect(readInvoice("billing", "inv/404.txt")).rejects.toThrow("does not exist");
  });
});
jest.config.cjs

/** @type {import('jest').Config} */
module.exports = {
  testEnvironment: "node",
  transform: { "^.+\.ts$": "@swc/jest" },
  testMatch: ["**/test/**/*.test.ts"],
};
Terminal

npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb @aws-sdk/client-s3
npm install --save-dev jest @jest/globals @swc/core @swc/jest typescript @types/node \
  aws-sdk-client-mock aws-sdk-client-mock-jest @smithy/util-stream
npx jest
# Tests:       8 passed, 8 total

Three details carry most of the value. The error test constructs a real ConditionalCheckFailedException with $metadata, so the instanceof check in the code under test behaves as it does in production; an Error with only a matching name would fall through to the rethrow. The paginator test only mocks ListObjectsV2Command: paginateListObjectsV2 calls send for each page and stops when there’s no NextContinuationToken. And the S3 body is a Node stream wrapped with sdkStreamMixin, which adds the transformToString() method the real SDK returns.

Running the same tests in Vitest

The mocks don’t change. Import the test functions from vitest and the matchers from aws-sdk-client-mock-jest/vitest:

test/orders.test.ts (Vitest)

// test/orders.test.ts: Vitest
import { beforeEach, describe, expect, it } from "vitest";
import { mockClient } from "aws-sdk-client-mock";
import "aws-sdk-client-mock-jest/vitest";
import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";
import { getOrder } from "../src/orders";

const ddbMock = mockClient(DynamoDBDocumentClient);

beforeEach(() => {
  ddbMock.reset();
});

describe("getOrder", () => {
  it("returns the item", async () => {
    ddbMock.on(GetCommand).resolves({ Item: { orderId: "O-1", customerId: "C-9", total: 42.5 } });
    await expect(getOrder("O-1")).resolves.toMatchObject({ orderId: "O-1" });
    expect(ddbMock).toHaveReceivedCommandWith(GetCommand, { Key: { orderId: "O-1" } });
  });
});
Terminal

npm install --save-dev vitest aws-sdk-client-mock aws-sdk-client-mock-jest chalk@5
npx vitest run

chalk@5 is there on purpose. In our test in September 2026, aws-sdk-client-mock-jest 4.1.0’s Vitest entry imported chalk without declaring it, and npm resolved the chalk 4 pulled in through the expect package, which fails with TypeError: Chalk is not a constructor. Installing chalk 5 as a dev dependency fixed it.

Which mock behaviors and matchers will you use most?

Need Call
Same answer every time mock.on(Cmd).resolves(output)
Different answer per call (pages, retries) .resolvesOnce(a).resolvesOnce(b).resolves(c)
Answer depends on input .on(Cmd, { Key: "a" }) or .callsFake((input) => ...)
AWS error .rejects(new SomeServiceException({ message, $metadata: {} }))
Fallback for any command mock.onAnyCommand().resolves({})
Command sent with input expect(mock).toHaveReceivedCommandWith(Cmd, partialInput)
Exact count expect(mock).toHaveReceivedCommandTimes(Cmd, 2)
Order expect(mock).toHaveReceivedNthCommandWith(2, Cmd, partialInput)
Raw calls mock.commandCalls(Cmd)[0].args[0].input

Retries are worth a test of their own. .rejectsOnce(throttlingError).resolves(output) checks your own retry loop, such as the UnprocessedItems handling in a batch writer. The SDK’s built-in retries sit in middleware that a stubbed send bypasses, so settings from the guide to configure retries and timeouts in AWS SDK for JavaScript v3 can’t be tested this way.

Existence checks are another common case. Code that treats a NotFound from HeadObjectCommand as “missing”, as in the guide to check if an S3 object exists in TypeScript with SDK v3, needs one test with .resolves({}) and one with .rejects(new NotFound({ message: "Not Found", $metadata: { httpStatusCode: 404 } })). The same goes for a publisher built like the one in the guide to publish an SNS message with AWS SDK v3 in TypeScript: mock PublishCommand and assert the TopicArn and message attributes it sent.

Permissions needed

None for the tests: a mocked client never calls AWS, so the test run needs no IAM policy and no credentials. The code under test still needs the right permissions in production, and mocks won’t tell you if they’re wrong. The guide to find the IAM actions your AWS SDK for JavaScript code needs covers that, and the IAM policy generator for TypeScript code drafts a policy from src/orders.ts for you to review. For this module it should come down to dynamodb:GetItem, dynamodb:PutItem, s3:ListBucket and s3:GetObject.

Troubleshooting and common mistakes

  • Argument of type 'typeof DynamoDBDocumentClient' is not assignable… Two versions of @smithy/types are installed. Align all @aws-sdk/* packages to one version and reinstall; npm ls @smithy/types shows the duplicates.
  • The real client still calls AWS. You mocked DynamoDBClient but the code sends through DynamoDBDocumentClient, or you mocked one instance and the code created another. Mock the class the code sends through.
  • A mock returns undefined. No behavior matched: the input matcher didn’t match, or a later, wider .on(Cmd) overrode it. By default an unmatched send resolves to undefined.
  • Tests pass alone but fail together. A missing reset() leaks behaviors and call history between tests.
  • beforeEach(() => mock.reset()) fails to type-check with @jest/globals. The arrow returns the stub, which isn’t a valid hook return type. Use a block body: beforeEach(() => { mock.reset(); }).
  • transformToString is not a function. The mocked S3 Body is a plain stream or string. Wrap it with sdkStreamMixin.

When should you use LocalStack instead of mocks?

Mocks test your code’s logic against the responses you assume. They can’t tell you that DynamoDB rejects a batch with duplicate keys, that an expression has a syntax error or that a bucket name is invalid. For that, run integration tests against an emulator such as LocalStack or DynamoDB Local by pointing the client at it with endpoint: "http://localhost:4566", or against a real sandbox account, and keep them in a separate, slower suite.

A good split: unit tests with mocks for every branch, a handful of integration tests for the requests you’re least sure about, such as a new ConditionExpression. The guides to query DynamoDB with AWS SDK v3 and send and receive SQS messages with AWS SDK v3 show modules whose tests follow the same pattern, and the AWS practical examples hub has scripts you can wrap in tests the same way.

Limits: what mocking can’t catch

  • Service-side validation, limits and error codes you didn’t think to mock.
  • IAM, SCPs, KMS key policies and VPC endpoints.
  • SDK middleware: retries, timeouts, endpoint resolution, checksums and signing.
  • Response shapes that changed since you wrote the mock. Typed mocks catch field names, not new behavior.

Frequently asked questions

How do I mock AWS SDK v3 in Jest?

Use aws-sdk-client-mock: const s3Mock = mockClient(S3Client), then s3Mock.on(PutObjectCommand).resolves({}). Reset it in beforeEach and assert with aws-sdk-client-mock-jest matchers.

How do I mock DynamoDBDocumentClient in SDK v3?

Call mockClient(DynamoDBDocumentClient) and mock the lib-dynamodb commands, such as GetCommand and PutCommand, with plain JavaScript objects instead of attribute-value maps.

How do I mock S3 GetObject Body in SDK v3?

Create a Node stream, wrap it with sdkStreamMixin from @smithy/util-stream, and resolve { Body: stream }. The code under test can then call transformToString().

Does aws-sdk-client-mock work with Vitest?

Yes. The mocks are framework-agnostic, and the matchers load with import "aws-sdk-client-mock-jest/vitest".

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud