Dan-Dev
Dan-Dev

Reputation: 9440

Mocking multiple boto3 services, some without moto implementation

I am trying to unit test the logic in a AWS Lambda function using mocking. The Lambda finishes it's execution by sending push notifications via AWS Pinpoint. The Lambda also uses AWS SSM Parameter Store. I have been mocking in other Lambdas, with multiple boto3 objects, with moto https://github.com/spulec/moto but there is no Pinpoint implementation in moto at present.

I found a solution in https://stackoverflow.com/a/55527212/839338 which I needed to modify to get it to work. The question it was answering was not about my exact scenario but the answer pointed me to a solution. So I am posting here to document my changes to the solution which I modified and to ask if there is a more elegant way of doing this. I have looked at botocore.stub.Stubber but can't see a way it is better, but I'm willing to be proved wrong.

My code so far:

test.py

import unittest
from unittest.mock import MagicMock, patch
import boto3
from moto import mock_ssm
import my_module


def mock_boto3_client(*args, **kwargs):
    if args[0] == 'ssm':
        # Use moto.
        mock_client = boto3.client(*args, **kwargs)
    else:
        mock_client = boto3.client(*args, **kwargs)
        if args[0] == 'pinpoint':
            # Use MagicMock.
            mock_client.create_segment = MagicMock(
                return_value={'SegmentResponse': {'Id': 'Mock SegmentID'}}
            )
            mock_client.create_campaign = MagicMock(
                return_value={'response': 'Mock Response'}
            )
    return mock_client


class TestMyModule(unittest.TestCase):
    @patch('my_module.boto3')
    @mock_ssm
    def test_my_module(self, mock_boto3):
        mock_boto3.client = mock_boto3_client
        conn = mock_boto3.client('ssm', region_name='eu-west-2')
        conn.put_parameter(
            Name='/my/test',
            Value="0123456789",
            Type='String',
            Tier='Standard'
        )
        response = my_module.handler()
        self.assertEqual(
            ('0123456789', 'Mock SegmentID', {'response': 'Mock Response'}), 
            response
        )

my_module.py

import boto3
import json


def get_parameter():
    ssm = boto3.client('ssm', region_name='eu-west-2')
    parameter = ssm.get_parameter(Name='/my/test')
    return parameter['Parameter']['Value']


def create_segment(client, message_id, push_tags, application_id):
    response = client.create_segment(
        ApplicationId=application_id,
        WriteSegmentRequest={
            'Dimensions': {
                'Attributes': {
                    'pushTags': {
                        'AttributeType': 'INCLUSIVE',
                        'Values': push_tags
                    }
                }
            },
            'Name': f'Segment {message_id}'
        }
    )
    return response['SegmentResponse']['Id']


def create_campaign(client, message_id, segment_id, application_id):
    message_payload_apns = json.dumps({
        "aps": {
            "alert": 'My Alert'
        },
        "messageId": message_id,
    })

    response = client.create_campaign(
        ApplicationId=application_id,
        WriteCampaignRequest={
            'Description': f'Test campaign - message {message_id} issued',
            'MessageConfiguration': {
                'APNSMessage': {
                    'Action': 'OPEN_APP',
                    'RawContent': message_payload_apns
                }
            },
            'Name': f'{message_id} issued',
            'Schedule': {
                'StartTime': 'IMMEDIATE'
            },
            'SegmentId': segment_id
        }
    )
    return response


def handler():
    application_id = get_parameter()
    client = boto3.client('pinpoint', region_name='eu-west-1')
    segment_id = create_segment(client, 12345, [1, 2], application_id)
    response = create_campaign(client, 12345, segment_id, application_id)
    return application_id, segment_id, response

In particular I would like to know how to implement mock_boto3_client() better and more elegantly to handle in a more generic way.

Upvotes: 1

Views: 5234

Answers (2)

Dan-Dev
Dan-Dev

Reputation: 9440

As I said in my comment in response to Bert Blommers answer

"I managed to register an additional service in the Moto-framework for pinpoint create_app() but failed to implement create_segment() as botocore takes "locationName": "application-id" from botocore/data/pinpoint/2016-12-01/service-2.json and then moto\core\responses.py tries to make a regex with it but creates '/v1/apps/{application-id}/segments' which has an invalid hyphen in it"

But I will post my working code for create_app() here for the benefit of other people who read this post.

The package structure is important in that the "pinpoint" package needs to be under one other package.

.
├── mock_pinpoint
│   └── pinpoint
│       ├── __init__.py
│       ├── pinpoint_models.py
│       ├── pinpoint_responses.py
│       └── pinpoint_urls.py
├── my_module.py
└── test.py

mock_pinpoint/pinpoint/init.py

from __future__ import unicode_literals
from mock_pinpoint.pinpoint.pinpoint_models import pinpoint_backends
from moto.core.models import base_decorator

mock_pinpoint = base_decorator(pinpoint_backends)

mock_pinpoint/pinpoint/pinpoint_models.py

from boto3 import Session
from moto.core import BaseBackend


class PinPointBackend(BaseBackend):

    def __init__(self, region_name=None):
        self.region_name = region_name

    def create_app(self):
        # Store the app in memory, to retrieve later
        pass


pinpoint_backends = {}
for region in Session().get_available_regions("pinpoint"):
    pinpoint_backends[region] = PinPointBackend(region)

mock_pinpoint/pinpoint/pinpoint_responses.py

from __future__ import unicode_literals
import json
from moto.core.responses import BaseResponse
from mock_pinpoint.pinpoint import pinpoint_backends


class PinPointResponse(BaseResponse):
    SERVICE_NAME = "pinpoint"

    @property
    def pinpoint_backend(self):
        return pinpoint_backends[self.region]

    def create_app(self):
        body = json.loads(self.body)
        response = {
            "Arn": "arn:aws:mobiletargeting:eu-west-1:AIDACKCEVSQ6C2EXAMPLE:apps/810c7aab86d42fb2b56c8c966example",
            "Id": "810c7aab86d42fb2b56c8c966example",
            "Name": body['Name'],
            "tags": body['tags']
        }
        return 200, {}, json.dumps(response)

mock_pinpoint/pinpoint/pinpoint_urls.py

from __future__ import unicode_literals
from .pinpoint_responses import PinPointResponse

url_bases = ["https?://pinpoint.(.+).amazonaws.com"]
url_paths = {"{0}/v1/apps$": PinPointResponse.dispatch}

my_module.py

import boto3


def get_parameter():
    ssm = boto3.client('ssm', region_name='eu-west-2')
    parameter = ssm.get_parameter(Name='/my/test')
    return parameter['Parameter']['Value']


def create_app(name: str, push_tags: dict):
    client = boto3.client('pinpoint', region_name='eu-west-1')
    return client.create_app(
        CreateApplicationRequest={
            'Name': name,
            'tags': push_tags
        }
    )


def handler():
    application_id = get_parameter()
    app = create_app('my_app', {"my_tag": "tag"})
    return application_id, app

test.py

import unittest
import boto3
from moto import mock_ssm
import my_module
from mock_pinpoint.pinpoint import mock_pinpoint


class TestMyModule(unittest.TestCase):
    @mock_pinpoint
    @mock_ssm
    def test_my_module(self):
        conn = boto3.client('ssm', region_name='eu-west-2')
        conn.put_parameter(
            Name='/my/test',
            Value="0123456789",
            Type='String',
            Tier='Standard'
        )
        application_id, app = my_module.handler()
        self.assertEqual('0123456789', application_id)
        self.assertEqual(
            'arn:aws:mobiletargeting:eu-west-1:AIDACKCEVSQ6C2EXAMPLE:apps/810c7aab86d42fb2b56c8c966example',
            app['ApplicationResponse']['Arn']
        )
        self.assertEqual(
            '810c7aab86d42fb2b56c8c966example',
            app['ApplicationResponse']['Id']
        )
        self.assertEqual(
            'my_app',
            app['ApplicationResponse']['Name']
        )
        self.assertEqual(
            {"my_tag": "tag"},
            app['ApplicationResponse']['tags']
        )

Having said that the solution in the original question works and is easier to implement but not as elegant.

Upvotes: 4

Bert Blommers
Bert Blommers

Reputation: 2123

It's relatively easy to use the moto framework for any new services. This allows you to focus on the required behaviour, and moto takes care of the scaffolding.

There are two steps required to register an additional service in the Moto-framework:

  1. Ensure that moto mocks the actual HTTP requests to https://pinpoint.aws.amazon.com
  2. Create a Responses class that acts on the requests for https://pinpoint.aws.amazon.com

Mocking the actual HTTP requests can be done by extending the BaseBackend-class from moto. Note the urls, and the fact that all requests to this url will be mocked by the PinPointResponse-class.

pinpoint_mock/models.py:

import re

from boto3 import Session

from moto.core import BaseBackend
from moto.sts.models import ACCOUNT_ID



class PinPointBackend(BaseBackend):

    def __init__(self, region_name):
        self.region_name = region_name

    @property
    def url_paths(self):
        return {"{0}/$": PinPointResponse.dispatch}

    @property
    def url_bases(self):
        return ["https?://pinpoint.(.+).amazonaws.com"]

    def create_app(self, name):
        # Store the app in memory, to retrieve later
        pass


pinpoint_backends = {}
for region in Session().get_available_regions("pinpoint"):
    pinpoint_backends[region] = PinPointBackend(region)
for region in Session().get_available_regions(
    "pinpoint", partition_name="aws-us-gov"
):
    pinpoint_backends[region] = PinPointBackend(region)
for region in Session().get_available_regions("pinpoint", partition_name="aws-cn"):
    pinpoint_backends[region] = PinPointBackend(region)

The Response-class needs to extend the BaseResponse-class from moto, and needs to duplicate the method-names that you're trying to mock.
pinpoint/responses.py

from __future__ import unicode_literals

import json

from moto.core.responses import BaseResponse
from moto.core.utils import amzn_request_id
from .models import pinpoint_backends


class PinPointResponse(BaseResponse):
    @property
    def pinpoint_backend(self):
        return pinpoint_backends[self.region]

    @amzn_request_id
    def create_app(self):
        name = self._get_param("name")
        pinpoint_backend.create_app(name)
        return 200, {}, {}

Now all that's left is creating a decorator:

from __future__ import unicode_literals
from .models import stepfunction_backends
from ..core.models import base_decorator

pinpoint_backend = pinpoint_backends["us-east-1"]
mock_pinpoint = base_decorator(pinpoint_backends)

@mock_pinpoint
def test():
    client = boto3.client('pinpoint')
    client.create_app(Name='testapp')

The code was taken from the StepFunctions-module, which is probably one of the simpler modules, and easiest to adapt to your needs: https://github.com/spulec/moto/tree/master/moto/stepfunctions

Upvotes: 2

Related Questions