Reputation: 9440
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
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
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:
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