Mock AWS Secrets Manager in Tests with moto

Code that reads a secret from AWS is easy to write and awkward to test: the obvious approaches either need real credentials, which puts production access on every developer machine and CI runner, or skip the code path entirely, which leaves the integration untested until it fails in staging. moto solves this by emulating the service in-process, so the same boto3 calls run against a fake that behaves like the real one. This page covers the fixture shape and the ordering rule that causes most of the confusion, extending the configuration testing patterns and the Secrets Manager integration.

Problem 1: a client created before the mock starts

# tests/test_secrets.py — ANTI-PATTERN
client = boto3.client("secretsmanager", region_name="eu-west-1")   # module import time

@mock_aws
def test_reads_the_secret():
    ...                       # this client still points at the real endpoint

moto patches botocore when it activates. A client constructed before that — at module import, in a session fixture, or inside a cached settings object built during collection — retains its real endpoint and attempts a genuine network call. In a sandboxed CI runner that surfaces as a connection timeout; on a developer machine with real credentials it may quietly succeed against a live account, which is considerably worse.

The rule is that every client must be constructed inside the mock’s active scope. In practice that means the fixture starts the mock, then builds the client, then yields — and application code must accept a client or construct one lazily rather than holding one at module level. That constraint is good design independently of testing: a module-level client is also awkward for credential refresh, region overrides and connection pooling.

Client construction order relative to the mock A client built before the mock activates keeps the real endpoint and attempts a network call, while a client built inside the mock's scope is intercepted in process. client first build client mock activates real endpoint — network call escapes mock first mock activates build client intercepted in process — no network the only difference is two lines swapping places
The mock patches botocore when it starts; anything constructed earlier never learns about it.

Problem 2: no credentials, or the wrong ones

# ANTI-PATTERN: relies on whatever the machine happens to have
@mock_aws
def test_reads_the_secret():
    boto3.client("secretsmanager").get_secret_value(SecretId="app/db")

botocore resolves credentials before moto intercepts the request, so a machine with no credentials raises NoCredentialsError and the test fails for a reason unrelated to what it is testing. The reverse case is more dangerous: on a machine with real credentials and a real default region, a small mistake in the mock’s scope can send the call to a live account.

The fix is to set fake credentials and an explicit region in the isolation fixture, so the test’s behaviour does not depend on the machine at all. Obviously-fake values — testing rather than something resembling a real key — also keep secret scanners quiet, and make it visually clear in a diff that nothing real is present.

Problem 3: testing the transport instead of the contract

# ANTI-PATTERN: asserts on the shape of the AWS response, not on your behaviour
assert response["ResponseMetadata"]["HTTPStatusCode"] == 200

Assertions like this test AWS, not your code. The interesting questions are whether your code asks for the right secret name, whether it parses the payload correctly, whether it wraps the value in SecretStr, and what it does when the secret is missing. Those are contract questions, and they survive an SDK upgrade; response metadata does not.

Frame each test around one of those questions. Create the secret in the fake, call your loader, assert on the resulting settings object. Delete the secret, call the loader, assert on the exception your code raises rather than the one boto3 raised — because the wrapper you put around a ResourceNotFoundException is the part that determines whether an operator can tell what went wrong.

Secure implementation

# tests/conftest.py — fake credentials, then the mock, then the client
import boto3
import pytest
from moto import mock_aws

REGION = "eu-west-1"


@pytest.fixture(autouse=True)
def aws_credentials(monkeypatch):
    # 1. resolved before moto intercepts, so they must exist and must be fake
    monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
    monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
    monkeypatch.setenv("AWS_SESSION_TOKEN", "testing")
    monkeypatch.setenv("AWS_DEFAULT_REGION", REGION)


@pytest.fixture
def secrets_client(aws_credentials):
    with mock_aws():                       # 2. the mock is active for this whole block
        client = boto3.client("secretsmanager", region_name=REGION)   # 3. built inside it
        yield client                       # 4. the test runs while the patch is in place


@pytest.fixture
def stored_secret(secrets_client):
    # 5. seed the fake with exactly what production would hold
    secrets_client.create_secret(
        Name="app/database",
        SecretString='{"username": "app", "password": "s3cret", "host": "db.internal"}',
    )
    return "app/database"
# tests/test_secret_loader.py
import pytest
from botocore.exceptions import ClientError
from secret_loader import load_database_settings, SecretNotConfigured


def test_loads_and_wraps_the_secret(secrets_client, stored_secret):
    settings = load_database_settings(secrets_client, stored_secret)
    assert settings.username == "app"
    # 6. the credential is wrapped, so a repr cannot leak it
    assert "s3cret" not in repr(settings)
    assert settings.password.get_secret_value() == "s3cret"


def test_missing_secret_raises_our_error(secrets_client):
    # 7. assert on the error the application defines, not the one boto3 raised
    with pytest.raises(SecretNotConfigured) as exc:
        load_database_settings(secrets_client, "app/absent")
    assert "app/absent" in str(exc.value)

Passing the client into load_database_settings rather than having it construct one is what makes the whole arrangement simple. The function under test takes its dependency as an argument, so the fixture controls exactly which client it uses and the ordering rule is satisfied by construction. If your production code currently builds its own client, adding an optional parameter that defaults to a lazily-constructed one is a small change that makes it testable without altering the calling convention anywhere.

The "s3cret" not in repr(settings) assertion deserves to be copied into every project that loads secrets. It is a one-line proof that the masking discipline is actually in force, and it fails the moment somebody types a credential field as str instead of SecretStr — which is precisely the mistake that no other test will catch and that leaks into a log six months later.

What a mocked secrets test should assert Useful assertions cover the requested name, the parsed payload, the masking of the value and the error raised when the secret is absent, rather than the HTTP metadata of the response. assert the contract, not the transport the right secret name is requested the payload parses into the model a missing secret raises your error survives an SDK upgrade these are properties of your code and of nothing else HTTP status assertions test AWS instead
Four questions worth asking, and one assertion that tells you nothing about your own behaviour.

When to reach for a stubber instead

moto is excellent for the happy path and for workflows spanning several calls — create a secret, read it, rotate it, read it again. It is less good at making a specific call fail in a specific way, and failure handling is often the part most worth testing, because it is the part nobody exercises manually.

botocore.stub.Stubber fills that gap. It attaches to a client and lets you queue exact responses and errors: a ThrottlingException on the first call and success on the second, to exercise a retry path; a ResourceNotFoundException, to prove the error wrapper names the missing secret; a malformed payload, to prove the parser fails cleanly rather than producing a half-populated model. Because the stubber also asserts the parameters of each expected call, it doubles as a check that your code asks for the right thing.

The two coexist comfortably in one suite. A reasonable division is moto for tests whose subject is the workflow and the data, Stubber for tests whose subject is an error path or an exact request shape. What you should avoid is reaching for unittest.mock.patch on the boto3 client itself: a bare MagicMock accepts any call with any arguments and returns another mock, so a test built on it passes even when the code calls a method that does not exist.

Gotchas and version-specific behaviour

  • Recent moto versions consolidate the service decorators into a single mock_aws; older code using mock_secretsmanager still works but is the deprecated spelling.
  • Every client must be created inside the mock’s scope, which includes clients held by cached settings objects — clear those caches in the fixture.
  • Fake credentials are mandatory, because botocore resolves them before moto intercepts the request.
  • moto does not enforce IAM, so a test cannot prove a permission boundary; that check belongs to a real deployment or a policy simulator.
  • Rotation-related APIs are emulated at varying depth; check behaviour against the real service before relying on a rotation test as evidence.
  • Stubber asserts expected parameters, so it catches a wrong secret name where a permissive mock would not.
  • Keep secret payloads in tests obviously fake, since fixtures get copied into examples, screenshots and issue reports where a realistic-looking value costs somebody a rotation.

Production parity checklist

  • Fake AWS credentials and an explicit region are set by an autouse fixture.
  • Clients are constructed inside the mock’s scope and injected into the code under test.
  • Assertions cover the requested name, the parsed result, the masking, and the error path.
  • At least one test proves the loaded credential does not appear in a repr of the settings object.
  • Error paths use a stubber to produce throttling and not-found responses deterministically.
  • A separate, scheduled integration test runs against a disposable real secret, outside the pull-request path.
Dividing the suite between moto, a stubber and a real integration test moto covers workflows and data, a stubber covers error paths and exact request shapes, and a scheduled integration test against a disposable secret covers provider-side change. moto workflows, payloads, happy paths Stubber throttling, not-found, exact request shape real, scheduled one disposable secret, outside the PR path
Three tiers with different purposes — only the first two belong in every pull request.

Frequently asked questions

Why does moto not intercept my boto3 call?

The client was created before the mock started. moto patches at the botocore layer when it activates, so any client built earlier keeps its real endpoint and will attempt a genuine network call. The usual culprits are a module-level client, a session-scoped fixture that builds one, or a cached settings object constructed during collection that holds a client as an attribute. Move client construction inside the mock’s scope, inject it into the code under test, and clear any cache that might be holding an earlier instance.

Do I still need AWS credentials in the environment when using moto?

Set fake ones. botocore resolves credentials before moto’s intercept, so an environment with no credentials raises NoCredentialsError, and an environment with real ones risks a test escaping to a live account if the mock’s scope is wrong. Setting obviously-fake values in an autouse fixture removes both risks and makes the suite behave identically on a laptop with a full AWS profile and on a bare CI container. Set the region explicitly for the same reason: an unset default region produces a confusing error that has nothing to do with the test.

Should I use moto or botocore’s Stubber?

Use moto when the test exercises a workflow across several calls, and Stubber when you want to assert one exact request or force a specific error response. They solve different problems and can coexist in a suite. Both are preferable to patching the client with a generic mock, which accepts any call with any arguments and therefore cannot fail when your code calls the wrong method — the failure surfaces in production instead, which is the opposite of what a test is for.

How do I test throttling and failure handling?

Stub the error rather than trying to provoke it. A Stubber can return a ThrottlingException or a ResourceNotFoundException on demand, which is the only reliable way to exercise a retry path — you cannot make a fake service throttle you on cue, and you certainly should not try against the real one. Queue the error, then queue a success, and assert both that the retry happened and that the eventual value is correct. That combination catches the two common bugs: no retry at all, and a retry that swallows the error and returns a partially-populated object.

Can I share a mocked secret across the whole session?

You can, but per-test setup is usually better. Secrets are cheap to create in moto, and a session-scoped fake becomes shared mutable state that reintroduces exactly the cross-test coupling the mock was meant to avoid — a test that rotates or deletes the secret changes what every later test sees. If setup cost genuinely matters, share the mock’s activation at session scope but create and delete the individual secrets per test, which keeps the expensive part shared and the mutable part isolated.

Key takeaways

Two rules make mocked AWS tests reliable: construct every client inside the mock’s active scope, and set fake credentials before anything resolves them. Both failures produce errors that look like problems with your code rather than with the fixture, which is why they cost so much time the first time they are encountered and so little once the fixture is written correctly.

Beyond the mechanics, keep the assertions on your own contract. Whether the right secret name is requested, whether the payload parses into a validated model, whether the credential is masked, and what happens when the secret is absent — those are the questions your tests can answer and AWS cannot. Add a stubber for the error paths, keep one real integration test on a schedule outside the pull-request path, and the whole area stops being something that only gets exercised during an incident. See the configuration testing topic for how this fits alongside environment isolation and settings fixtures.