Mocking AWS Services with moto in pytest

moto replaces the AWS API in-process so the boto3 calls your IaC helper code makes return realistic responses without a network round-trip or real credentials. This task sits within Testing Python Infrastructure Code, part of the wider Python IaC fundamentals and strategy toolkit, and it is the right tool whenever your infrastructure code drops to the AWS SDK for a lookup or an imperative step.

Context

Pulumi and CDKTF cover most resource provisioning, but real projects still reach for boto3 to look up an existing VPC, resolve the latest AMI, or read a parameter before building the resource graph. That SDK code needs the same test coverage as the rest of your infrastructure. moto intercepts boto3 at the client level and serves an in-memory AWS, so you can assert what your helper does with the response and even inspect the calls it made — all in milliseconds and with no live account.

The interception happens inside botocore, not over the network. moto registers handlers on botocore's event system so that when a client dispatches an operation, the request is answered from a Python object model of the service instead of being signed and sent. That model is stateful: create a VPC, tag it, and a later describe_vpcs in the same test sees the tag, complete with a generated vpc- identifier of the right shape. It is also account-scoped — every mocked call runs against account 123456789012 unless you override MOTO_ACCOUNT_ID — which matters when your helper builds ARNs and asserts on them.

Four ways to test boto3 code, and what each proves Four ways to test boto3 code, and what each proves: comparison across Realistic state, Needs network, Proves. Technique Realistic state Needs network Proves unittest.mock No No The call was made botocore Stubber No No Response shape is handled moto Yes, in memory No Call sequence and logic Real account Yes Yes The API truly accepts it
moto sits where realistic state matters but a live account does not.

The technique's boundary is worth stating plainly. moto proves that your call sequence, your filter syntax and your response parsing are right. It does not prove that IAM will allow the call, that a service quota permits it, or that the real API accepts a parameter combination moto is lenient about. Those belong to an integration job against a sandbox account, run far less often. Everything before that boundary should be a moto test, because it costs nothing and runs on every commit.

Prerequisites

  • Python 3.9+ and pytest>=7.
  • pip install "moto[ec2,s3]>=5" (moto 5 consolidated decorators into mock_aws).
  • boto3>=1.26 as a direct dependency of your IaC helper package.
  • Dummy AWS credentials set in the test environment so boto3's credential chain does not error before moto intercepts.
  • No real IAM permissions — moto never contacts AWS.
  • Helper code that constructs its boto3 clients lazily, inside functions rather than at module import.
Which fake belongs in this test? Which fake belongs in this test?: choose among 4 options. What is the assertion about? arguments unittest.mock on theclient state moto with seededresources errors Stubber with aClientError quotas Real account,integration job
Pick the cheapest fake that can still fail for the reason you care about.

That last point is a design constraint, not a preference. A client created at import time is built before any decorator or fixture runs, so it holds a real endpoint and escapes the mock entirely — the test then either reaches AWS or fails with a connection error, depending on the runner. Passing a client in as an argument, or building it inside the function, keeps the code testable and also makes the region an explicit parameter rather than ambient state.

Implementation

How moto intercepts a call your helper makes How moto intercepts a call your helper makes: pytest → helper code → botocore → moto backend. pytest helper code botocore moto backend call find_vpc_by_tag describe_vpcs patched dispatch in-memory response parsed dict vpc-0a1b2c3d
The patch is installed in botocore, which is why a client built before the mock starts escapes it.

1. Pin fake credentials and a region in a fixture

boto3 resolves credentials and region before any call, so set throwaway values to keep the chain from reaching a real profile.

# CLI: pytest tests/test_lookups.py -v
# Provider note: these creds are never sent anywhere; moto answers locally.
import os
import pytest

@pytest.fixture(autouse=True)
def aws_env(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
    monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
    monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
    monkeypatch.delenv("AWS_PROFILE", raising=False)

Deleting AWS_PROFILE matters as much as setting the keys. A developer with AWS_PROFILE=prod exported in their shell has a credential source that outranks the environment keys, and moto's coverage gaps are exactly where that profile would be used for real. Put this fixture in conftest.py with autouse=True so no test can opt out of it by accident.

2. Wrap the test body in mock_aws and seed state

The single mock_aws decorator covers every service. Create the resources your helper expects to find, then call the helper.

# CLI: pytest tests/test_lookups.py::test_finds_tagged_vpc -v
# State implication: all resources live in moto's in-memory store and vanish at test exit.
import boto3
from moto import mock_aws

def find_vpc_by_tag(name: str, region: str = "us-east-1") -> str:
    """IaC helper: resolve a VPC id by its Name tag before building the stack."""
    ec2 = boto3.client("ec2", region_name=region)
    resp = ec2.describe_vpcs(Filters=[{"Name": "tag:Name", "Values": [name]}])
    return resp["Vpcs"][0]["VpcId"]

@mock_aws
def test_finds_tagged_vpc() -> None:
    ec2 = boto3.client("ec2", region_name="us-east-1")
    created = ec2.create_vpc(CidrBlock="10.0.0.0/16")["Vpc"]["VpcId"]
    ec2.create_tags(Resources=[created], Tags=[{"Key": "Name", "Value": "core"}])

    assert find_vpc_by_tag("core") == created

Note what the seeding step buys you beyond a canned response: the filter itself is under test. Write tag:name instead of tag:Name and the assertion fails, because moto applies filters the way EC2 does. A hand-rolled mock that returns a fixed dictionary would have passed, and the bug would have surfaced the first time the helper ran against a real account.

3. Cover the empty and paginated cases

Every lookup helper has two failure modes that production finds and a happy-path test does not: nothing matched, and more matched than fit in one page. Both are trivial to reproduce under moto and neither is reproducible with a stub.

# CLI: pytest tests/test_lookups.py -k "missing or paginat" -v
from dataclasses import dataclass

import boto3
import pytest
from moto import mock_aws


class VpcNotFound(LookupError):
    """Raised when no VPC carries the requested Name tag."""


def find_vpc_or_raise(name: str, region: str = "us-east-1") -> str:
    ec2 = boto3.client("ec2", region_name=region)
    resp = ec2.describe_vpcs(Filters=[{"Name": "tag:Name", "Values": [name]}])
    vpcs = resp["Vpcs"]
    if not vpcs:
        raise VpcNotFound(f"no VPC tagged Name={name} in {region}")
    return vpcs[0]["VpcId"]


def all_parameters(path: str, region: str = "us-east-1") -> list[str]:
    ssm = boto3.client("ssm", region_name=region)
    paginator = ssm.get_paginator("get_parameters_by_path")
    return [p["Name"] for page in paginator.paginate(Path=path, Recursive=True)
            for p in page["Parameters"]]


@mock_aws
def test_missing_vpc_raises_domain_error() -> None:
    with pytest.raises(VpcNotFound):
        find_vpc_or_raise("does-not-exist")


@mock_aws
def test_paginates_beyond_one_page() -> None:
    ssm = boto3.client("ssm", region_name="us-east-1")
    for i in range(60):  # SSM returns at most 10 per page by default
        ssm.put_parameter(Name=f"/app/p{i:02d}", Value=str(i), Type="String")

    assert len(all_parameters("/app")) == 60

The first test is the reason to raise a domain error rather than let resp["Vpcs"][0] throw: an IndexError from deep inside a helper tells an on-call engineer nothing, while VpcNotFound: no VPC tagged Name=core in us-east-1 names the missing input. The second is a genuine bug class — code that reads response["Parameters"] once and silently drops everything past the first page.

4. Assert the calls your helper made

When the side effect matters more than the return value, wrap the real boto3 method with a spy to confirm the helper called it correctly.

# CLI: pytest tests/test_lookups.py::test_writes_parameter -v
from unittest.mock import patch
import boto3
from moto import mock_aws

def store_endpoint(name: str, value: str, region: str = "us-east-1") -> None:
    # State implication: a real SSM PutParameter is a side effect; verify it is called once.
    boto3.client("ssm", region_name=region).put_parameter(
        Name=name, Value=value, Type="String", Overwrite=True
    )

@mock_aws
def test_writes_parameter() -> None:
    real_put = boto3.client("ssm", region_name="us-east-1").put_parameter
    with patch("boto3.client") as mk:
        mk.return_value.put_parameter.side_effect = real_put
        store_endpoint("/app/endpoint", "https://api.internal")
        mk.return_value.put_parameter.assert_called_once()

Use the spy sparingly. Wherever the effect is observable in moto's state — and a written parameter is — asserting on a follow-up get_parameter is the stronger test, because it survives a refactor that changes which SDK method the helper reaches for.

Verification

Run the suite and confirm it passes with no network access; the assertions prove both the return value and the call were correct.

# CLI: confirm the mocked suite is hermetic
pytest tests/ -q
# Provider note: unplug the network and it still passes — moto needs no connectivity.

Two further checks are worth wiring in permanently. First, run the suite with no AWS environment at all — env -u AWS_PROFILE -u AWS_ACCESS_KEY_ID pytest tests/ -q — so a test that quietly depends on a developer's credentials fails on the machine that has none, rather than in CI a week later. Second, keep an eye on wall-clock time; a moto suite that starts taking seconds per test usually means state is being seeded far beyond what the assertion needs, or a mock_aws context is being entered and exited inside a loop.

Feedback loop for the same twenty assertions Feedback loop for the same twenty assertions: moto, in process, LocalStack container, Shared sandbox account, Full deploy and destroy. moto, in process under 1s LocalStack container ~14s plus startup Shared sandbox account ~95s, rate limited Full deploy and destroy ~7 min, costs money
The gap is not just speed: only the first two can run on a pull request from a fork.

Gotchas & Edge Cases

Where moto-based tests actually fail Where moto-based tests actually fail: Broken isolation with 4 facets. Broken isolation NoRegionError client built with no region configured Module-level client created before the mock was installed Leaked profile no dummy credentials, real account reachable Unimplemented API moto raises NotImplementedError for the operation
Three of these are ordering or configuration mistakes; only the fourth is a moto limitation.

Missing region raises NoRegionError even under moto. moto patches the API, not boto3's configuration resolution. Always set AWS_DEFAULT_REGION (or pass region_name) or the client construction fails before the mock engages.

moto 5 removed the per-service decorators. Code written for moto 4 with @mock_ec2 or @mock_s3 breaks on upgrade. Use the single @mock_aws decorator (or with mock_aws():) for all services.

Real credentials can leak through if the fixture is missing. Without the dummy-credential fixture, boto3 may pick up a live profile and, for services moto does not fully emulate, hit real AWS. Keep the autouse env fixture in conftest.py so every test is isolated.

A client created at import time is never mocked. ec2 = boto3.client("ec2") at module scope is constructed when the module is imported, which is before the decorator runs. The symptom is a test that hangs or fails with an endpoint connection error while an identical test using a locally built client passes.

moto does not enforce IAM by default. Every call succeeds regardless of the policy your code will actually run under, so a test suite can be entirely green against a role that would be denied in production. moto can emulate access control when MOTO_ENABLE_IAM_ACCESS_CONTROL is set, but the practical answer for permission bugs is a policy check in the pipeline rather than a unit test.

An unimplemented operation raises NotImplementedError. moto's coverage is deep but not complete, and the failure names the operation — for example, The list_foo action has not been implemented. That is a signal to move the case into an integration test, not to work around it by mocking the client and losing the state model.

Sharing a mock across tests leaks state. A module- or session-scoped fixture that enters mock_aws() once keeps every resource created by every test, so ordering starts to matter and a failure in test twelve is caused by test three. Keep the scope at function level unless the seeding is genuinely expensive.

Operational Notes

moto shines for the imperative boto3 code that sits alongside your declarative infrastructure — the lookups, migrations, and one-off actions that a provider does not model. Wrap the code under test with @mock_aws (or the fixture form) and it will create, read, and mutate a fully in-memory AWS, so assertions are deterministic and free.

Moto test stack Moto test stack: layered from pytest fixture down to in-memory AWS state. pytest fixture @mock_aws decorator boto3 client (intercepted) in-memory AWS state
moto intercepts boto3 calls and serves them from an in-memory model, so tests never touch real AWS.

Prefer the fixture form over the decorator once more than a couple of tests need the same starting state. A function-scoped fixture that enters mock_aws(), seeds the resources, and yields a client keeps the seeding in one place and still resets everything at teardown, because leaving the context manager discards the backend. It also composes with the credential fixture cleanly: pytest resolves both before the test body runs, in the order their dependencies require.

For code that is not Python — a shell script in a migration, or a tool you shell out to — the same backend is available over HTTP through ThreadedMotoServer, which listens on a local port and answers any SDK pointed at it with endpoint_url. It is heavier than in-process interception and it needs the endpoint threaded through your configuration, so reach for it only when the caller genuinely cannot be patched in-process.

The caveat is fidelity: moto implements a large but incomplete subset of AWS behaviour, and some services or newer parameters are unsupported or simplified. Treat a moto pass as evidence your call sequence and error handling are correct, not proof the real API will behave identically. Throttling is the clearest example — moto never returns ThrottlingException, so retry and backoff logic has to be tested by forcing the exception through unittest.mock instead. For resource lifecycle managed by Pulumi or CDKTF, prefer Pulumi mocks or snapshot tests, and reserve moto for the SDK glue.

FAQ

Does moto support every AWS service? No. Core services (EC2, S3, IAM, SSM, DynamoDB, Lambda) are well covered, but coverage thins for newer or niche APIs. Check the moto implementation table for your service; for gaps, prefer LocalStack in an integration test.

Should I mock boto3 or use moto? Use moto when you want realistic AWS behavior and state (create a VPC, then describe it). Use plain unittest.mock when you only need to assert that a specific call was made with specific arguments and do not care about realistic responses.

Can I share seeded moto state across tests? Use a pytest fixture that enters mock_aws() and yields a seeded client. Each test still gets a fresh in-memory account because the context manager resets state on exit, which is what you want for isolation.

Why does my test hit real AWS despite the decorator? The boto3 client was almost certainly created before the mock was installed — usually at module import, occasionally in a session-scoped fixture. Build clients inside the function under test, or inject them as arguments, so construction happens while the patch is active.

How do I test error handling if moto always succeeds? Reproduce the error through state where you can: delete the parameter and let moto return ParameterNotFound, or query a bucket that was never created. For errors moto does not model, such as throttling, patch the client method to raise the corresponding botocore.exceptions.ClientError and assert your retry path.

Which AWS account ID do mocked resources belong to? 123456789012 by default, so any ARN your helper constructs or parses in a test will contain it. Override it with the MOTO_ACCOUNT_ID environment variable when a test needs to prove that cross-account logic reads the account from the caller rather than hard-coding it.