Testing Boto3 with pytest Fixtures

This is a recipe I’ve used on a number of projects. It combines pytest fixtures with Botocore’s Stubber for an easy testing experience of code using Boto3. (Botocore is the library behind Boto3.)
Example App
Imagine we have a Boto3 resource defined in app/aws.py
:
import boto3
s3_resource = boto3.resource("s3")
And a function to test in app/s3_functions.py
:
def do_something(bucket, key):
...
We want to write tests for do_something()
.
Test Fixture
In our tests we want to ensure that:
- The real S3 service is never touched
- We can make accurate assertions on all S3 requests
- We expect all the requests the system makes
We could turn to unittest.mock
, but its mocking is heavy-handed and would remove boto3
’s argument checking. (Boto3 is not autospec
friendly). Instead let’s use Botocore’s Stubber.
A Stubber can temporarily patch a client to avoid contacting its service and instead interact with a queue of pre-declared responses.
We can set one up in a pytest fixture in a file called tests/conftest.py
like so:
import pytest
from botocore.stub import Stubber
from app.aws import s3_resource
@pytest.fixture(autouse=True)
def s3_stub():
with Stubber(s3.meta.client) as stubber:
yield stubber
stubber.assert_no_pending_responses()
Note:
- We put the fixture in the
conftest.py
in the basetests
directory so it is available in all tests. - We set
autouse=True
so that pytest applies the fixture to every test, regardless of whether the test requests it. - We access the
boto3
Resource’s underlying Client with.meta.client
. If our application used a Client we could stub it client directly. - We yield the
stubber
as the fixture object so tests can make use of it. - We use the stubber’s
assert_no_pending_responses()
at the end to check that the test made every expected request.
Testing
We could use the fixture in a test file called tests/s3_functions/test_do_something.py
like so:
from app import do_something
def test_do_something(s3_stub):
s3_stub.add_response(
"head_object",
expected_params={"Bucket": "example-bucket", "Key": "foobar"},
service_response={},
)
result = do_something(bucket="example-bucket", key="foobar")
assert result == ...
This follows the “Arrange, Act, Assert” (AAA) pattern for writing tests.
To arrange, we declare the expected botocore responses and their parameters. Here there is just one, a call to head_object
. When given as positional arguments, the expected_params
and service_response
arguments are reversed, but I prefer to use keyword arguments and put them in natural “request, response” order. expected_params
is also optional, but it improves our test coverage to include it. If it’s hard to compute one or more of the expected parameters, you can use botocore.stub.ANY
as a stand-in value which always compares equal.
To act, we call the tested function with test parameters. The Stubber will match incoming requests against the declared requests and return the matching responses. We should declare the requests in expected order, since they will act as a queue.
To assert, we make some standard pytest assertions. I didn’t fill them in here. During teardown the fixture runs stubber.assert_no_pending_responses()
as a final assertion.
Testing AWS Errors
We can also emulate the AWS service raising an error by using the Stubber’s add_client_error
method.
We could use it to test do_something
’s behaviour with a missing S3 key like so:
def test_do_something_missing_object(s3_stub):
s3_stub.add_client_error(
"head_object",
expected_params={"Bucket": "example-bucket", "Key": "foobar"},
service_error_code="404",
)
with pytest.raises(ValueError) as excinfo:
result = do_something(bucket="example-bucket", key="foobar")
assert str(excinfo.value) == "do_something provided with an invalid object key"
Finding the value for service_error_code
value is a matter of looking in the relevant service’s API documentation, for example the S3 Error Responses page. Or, more robustly, cause that error manually with Boto3 and find it in the raised exception.
We’re using pytest.raises
to capture the ValueError
from do_something
. This prevents the exception failing the test and allows us to make assertions about it.
Make your development more pleasant with Boost Your Django DX.
One summary email a week, no spam, I pinky promise.
Related posts: