Why AWS SDK Code Needs Its Own Testing Strategy
Code that calls s3.get_object or new PutItemCommand(...) is not like ordinary application code.
Search across all documentation pages
Code that calls s3.get_object or new PutItemCommand(...) is not like ordinary application code.
Every call can hit the network, cost money, depend on account state, and fail in ways that have nothing to do with your logic. A naive test suite that calls real AWS on every run is slow, flaky, and expensive.
This page lays out the mental model behind the rest of this section: where to draw the boundary around an AWS call, and which of the four common tools to reach for at each layer.
Most bugs in AWS-calling code are not AWS bugs. They're your code passing the wrong bucket name, mishandling a paginated response, or not catching a specific error code.
None of that requires talking to a real S3 bucket to verify. It requires isolating the one line that calls the SDK, and testing everything around it.
The isolation boundary is the SDK client object itself: boto3.client("s3") in Python, or new S3Client({}) in TypeScript. Everything downstream of that boundary - the actual network call, the service, the region - is not your code's responsibility to verify in a unit test.
# --- Python (boto3) ---
# The function you want to unit test.
import boto3
def get_report_size(bucket: str, key: str) -> int:
s3 = boto3.client("s3")
head = s3.head_object(Bucket=bucket, Key=key)
return head["ContentLength"]// --- TypeScript (AWS SDK v3) ---
// The function you want to unit test.
import { S3Client, HeadObjectCommand } from "@aws-sdk/client-s3";
export async function getReportSize(s3: S3Client, bucket: string, key: string): Promise<number> {
const head = await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
return head.ContentLength ?? 0;
}In both cases, the function under test does something with an SDK client. A test that stubs out the client's behavior can verify the function's logic without any real network call.
There isn't one right way to fake AWS. There are four, each trading realism for speed and cost, and each suited to a different kind of test.
In-process mocking patches the SDK's HTTP layer inside your test process. moto does this for boto3 with the @mock_aws decorator; aws-sdk-client-mock does it for SDK v3 by wrapping the client's send() method. No network call ever happens. These run in milliseconds and are the right default for unit tests.
Local emulation runs a real server - LocalStack - as a Docker container, and you point your SDK client at it with a custom endpoint. This is heavier than in-process mocking but exercises more real behavior: actual HTTP round trips, closer-to-real service semantics, and interactions between services that a simple mock can't fake (an S3 event triggering a Lambda, for instance).
Contract tests against real AWS run a small, deliberately scoped suite against an actual sandbox AWS account, usually in CI on a schedule or before a release. Nothing else can catch a real IAM permission edge case or a genuine service quirk, but every run costs real requests and sometimes real resources that must be cleaned up.
The failure mode to avoid is picking one tool for everything. Unit tests that spin up LocalStack for every assertion are needlessly slow; a whole suite that hits real AWS is needlessly expensive and flaky in CI.
Think of the four tools as a pyramid, in order of test count from bottom to top:
| Layer | Tool | Speed | Realism | Typical Count |
|---|---|---|---|---|
| Unit | moto / aws-sdk-client-mock | Milliseconds | Low - simulated responses | Hundreds |
| Integration | LocalStack | Seconds | Medium - real server, simulated backend | Dozens |
| Contract | Real sandbox AWS account | Seconds-minutes | High - real service | A handful |
The base of the pyramid should be the vast majority of your tests, because it's cheap enough to run on every commit. LocalStack tests are worth writing for behavior that in-process mocks structurally can't cover, like cross-service triggers or exact wire-level quirks. Contract tests exist to catch what the other two layers can't: real permission boundaries, real quota errors, real service behavior drift after an AWS-side change.
A common mistake is treating LocalStack as a mock replacement rather than an integration layer - if a plain @mock_aws test would catch the bug just as well, LocalStack is only adding container startup time for no benefit.
In-process mocking - moto for Python, aws-sdk-client-mock for TypeScript. It's the fastest to set up and the fastest to run, and it covers the majority of what you need to verify in unit tests.
Only when you need integration behavior a simple mock can't fake, like real service-to-service triggers or closer-to-real wire semantics. Many projects never need it.
Cost, speed, and flakiness. Real calls take longer, cost money, and can fail for reasons unrelated to your code, like transient AWS-side throttling.
The SDK client object - boto3.client(...) or an SDK v3 *Client instance. Everything below that boundary is AWS's responsibility, not yours to re-verify.
For most teams, a small contract suite is worth having, because some bugs (permission edges, quota behavior) only show up against the real service. It should stay small and deliberate.
Stack versions: This page was written for boto3 1.43.x (Python 3.10+) and the AWS SDK for JavaScript v3 (Node.js 18+).
Reviewed by Chris St. John·Last updated Jul 23, 2026