Tests that hit real AWS are slow, flaky and cost money; mocks make AWS code testable in CI.
Two complementary tools:
moto (pip install moto) implements AWS services in memory. Decorate a test or use a context manager with mock_aws(), and every boto3 call inside goes to moto instead of AWS: you can create buckets, tables and queues and your code works against them with realistic behavior (errors, pagination, conditions). Set fake credentials (AWS_ACCESS_KEY_ID=testing and friends) and a region in a fixture so tests can never touch a real account. Create clients inside the mock, or at least after it starts.
botocore's Stubber works at the client level: you queue up expected calls with stubber.add_response("get_item", response_dict, expected_params) or add_client_error("get_item", "ResourceNotFoundException"). It asserts that your code makes exactly those calls with exactly those parameters and returns your canned responses; assert_no_pending_responses() checks that all expected calls happened. It's precise but brittle, and responses must match the service's response shape.
Use moto for most tests of AWS-facing logic, Stubber when you must assert exact parameters or simulate rare errors. For integration tests closer to reality, LocalStack runs AWS APIs in a container, reachable with endpoint_url=.
Whatever you choose, keep AWS calls behind small functions that take a client parameter: that makes injection trivial.
Example
# test_storage.py (pip install "moto[s3]" pytest)
import boto3, pytest
from botocore.stub import Stubber
from moto import mock_aws
def save_report(s3, bucket, name, text):
s3.put_object(Bucket=bucket, Key=f"reports/{name}", Body=text.encode())
return f"s3://{bucket}/reports/{name}"
@pytest.fixture
def aws_env(monkeypatch):
for k in ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"):
monkeypatch.setenv(k, "testing")
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
@mock_aws
def test_save_report_with_moto(aws_env):
s3 = boto3.client("s3"); s3.create_bucket(Bucket="acme-reports")
assert save_report(s3, "acme-reports", "q1.txt", "hi") == "s3://acme-reports/reports/q1.txt"
assert s3.get_object(Bucket="acme-reports", Key="reports/q1.txt")["Body"].read() == b"hi"
def test_save_report_with_stubber(aws_env):
s3 = boto3.client("s3")
with Stubber(s3) as stub:
stub.add_response("put_object", {}, {"Bucket": "acme-reports", "Key": "reports/x", "Body": b"y"})
save_report(s3, "acme-reports", "x", "y")
stub.assert_no_pending_responses()Remember
- moto's mock_aws runs AWS services in memory for realistic tests
- Always set fake credentials and a region in test fixtures
- Stubber asserts exact calls and returns canned responses
- Pass clients into functions so tests can inject them
Watch out for
- Creating a module-level client before mock_aws starts, so tests talk to real AWS.