Adam Johnson

Home | Blog | Projects | Colophon

Testing Boto3 with Pytest Fixtures

2019-04-22

Testing Apparatus

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:

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:

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.

Fin

I hope this recipe helps you build more robust AWS integrations,

—Adam


If you found this useful, I'd be grateful if you subscribe to my future posts, via RSS, Twitter, or email:

Or perhaps you'd like to read a related post:

Tags: aws, python