Search code examples
pythonmockingboto3python-unittest

How to mock bucket.objects.filter() from boto3 resource?


I am having some problems mocking the bucket.objects.filter() method, but I am able to mock most other boto3 calls.

I have a class that is similar to this code in a file with the path my_project.utils.s3_api:

from boto3.session import Session, Config


class S3Resource:
    def __init__(self, kwargs):
        session = Session()
        self.client = session.resource(
            's3',
            aws_access_key_id=kwargs['access_key'],
            aws_secret_access_key=kwargs['secret_key'],
            endpoint_url=kwargs['s3_url'],
            region_name=kwargs['region'],
            config=Config(signature_version='s3v4'
        )
        self.kwargs = kwargs

    <many methods that are already successfully tested here>

    # this is the method that I cannot test correctly:
    def list_bucket_contents(self):
        bucket = self.client.Bucket(self.kwargs['bucket'])
        return [summary.key for summary in bucket.objects.filter()]

Then in my tests file I have something like this:

    @mock.patch('my_project.utils.s3_api.Session.resource')
    def test_list_bucket_contents(self, mock_connection):
        ObjectSummary = namedtuple('ObjectSummary', 'bucket_name key')
        obj_collection = (
            ObjectSummary(bucket_name='mybucket', key='file1.txt'),
            ObjectSummary(bucket_name='mybucket', key='file2.txt'),
            ObjectSummary(bucket_name='mybucket', key='file3.txt')
        )

        mock_client = mock.MagicMock()
        mock_client.filter.return_value = obj_collection
        mock_connection.return_value = mock_client

        s3_client = S3Resource(**self.init_args)
        s3_client.list_bucket_contents()
        print(result)

The returned list is always empty.

The namedtuple part is just an attempt to mimic the bucket.objects.

I would be open to solutions using botocore Stub, but I cannot use a third party library like moto. I just need to mock the call to bucket.objects.filter(). Thank you in advance.


Solution

  • Short answer: replace mock_connection.return_value = mock_client with mock_connection.return_value.Bucket.return_value.objects = mock_client in your test and it would work.

    The reason is that self.client is session.resource that you mock, then you create a Bucket (which adds the Bucket.return_value to the mocked path), then you do .objects and only then do you apply the filter() (adds filter.return_value which you already had).

    A simple approach that can help you in future cases is to use a helper library I wrote to generate the asserts for you: the mock-generator.

    To use it in your case, add mock_autogen.generate_asserts(mock_connection) right after your call to list_bucket_contents, like so:

    import mock_autogen
    
    
    @mock.patch('my_project.utils.s3_api.Session.resource')
    def test_list_bucket_contents(self, mock_connection):
        ObjectSummary = namedtuple('ObjectSummary', 'bucket_name key')
        obj_collection = (
            ObjectSummary(bucket_name='mybucket', key='file1.txt'),
            ObjectSummary(bucket_name='mybucket', key='file2.txt'),
            ObjectSummary(bucket_name='mybucket', key='file3.txt')
        )
    
        mock_client = mock.MagicMock()
        mock_client.filter.return_value = obj_collection
        mock_connection.return_value = mock_client
    
        s3_client = S3Resource(**self.init_args)
        s3_client.list_bucket_contents()
        mock_autogen.generate_asserts(mock_connection)
    

    It would print all the needed asserts for mock_connection, which would tell you the exact methods used. In your case, the input would contain the following line:

    mock_connection.return_value.Bucket.return_value.objects.filter.assert_called_once_with()

    From this line you can derive the path you need to mock and replace with the filter objects.