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.
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.
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 usingmock_secretsmanagerstill 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.
Stubberasserts 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
autousefixture. - 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
reprof 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.
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.