How to Test Amazon SES Emails Using Pytest and Moto
In this post I will show you a very neat way to unit test your Amazon SES emails using Pytest and the awesome Moto library for mocking AWS services.
How I Define SES Emails in Application Code
I like to use a handy base class for preparing and sending SES emails within my application code. Here is a simple example:
# emails/base.py
import abc
import json
import boto3
class BaseEmail(abc.ABC):
"""Base e-mail definition.
Subclasses of this base class are responsible for building the
appropriate template data required by the SES e-mail service.
"""
ses_template_name: str
def get_ses_template_name(self) -> Optional[str]:
if self.ses_template_name:
return self.ses_template_name
return None
@abc.abstractmethod
def get_template_data(self) -> Dict[str, Any]:
"""Builds and returns the template data to insert in text and
HTML email templates.
Returns:
A JSON serializable dictionary with all template variables
"""
def send_email(self, to_addresses: List[str]):
"""Sends the email using Amazon SES.
Args:
to_addresses: List of email addresses to send the email to.
Raises:
EmailFailedException if email was not sent successfully.
"""
session = boto3.session.Session()
ses = session.client(service_name="ses")
template_name = self.get_ses_template_name()
if not template_name:
raise ValueError(f"Invalid SES template name for {self}")
response = ses.send_templated_email(
Source="lalala", # Get this from your app configuration/constant
Destination={"BccAddresses": to_addresses},
Template=self.ses_template_name,
TemplateData=json.dumps(self.get_template_data()),
ConfigurationSetName=config.environment,
)
All subclasses will be tied to SES by the SES template names. Let's assume that we have all our SES email templates stored as JSON files somewhere in our project. Here is an example of a template definition file:
{
"Template": {
"TemplateName": "ForgotPassword",
"SubjectPart": "FOo bar",
"HtmlPart": "<some html here>",
"TextPart": "some text here"
}
}
So now let's implement a Python email definition using this base class:
# emails/users.py
from emails.base import BaseEmail
class ForgotPasswordEmail(BaseEmail):
ses_template_name = "ForgotPassword"
def __init__(self, user):
self.user = user
def get_template_data(self):
return {
"username": self.user.username,
"link": "http://myapp.com/resetpassword",
}
Now we can actually send this email easily:
email = ForgotPasswordEmail(user)
email.send_email(to_addresses=[user.email])
Testing It
In order to test everything we just covered we will be working with a mocked SES client that will be available to our test suite as a fixture. This fixture will also be in charge of creating SES email templates within our test suite. These templates are also mocks.
# tests/conftest.py
import os
import glob
import json
import boto3
import pytest
from moto import mock_ses
EMAIL_TEMPLATES_PATH = "emails/templates"
@pytest.fixture
def aws_credentials():
os.environ["AWS_ACCESS_KEY_ID"] = "testing"
os.environ["AWS_SECRET_ACCESS_KEY"] = "testing"
os.environ["AWS_SECURITY_TOKEN"] = "testing"
os.environ["AWS_SESSION_TOKEN"] = "testing"
os.environ["AWS_DEFAULT_REGION"] = "us-east-1"
@pytest.fixture
def ses(aws_credentials):
with mock_ses:
ses = boto3.client("ses", region_name="us-east-1")
# Create the templates
template_files = glob.glob(f"{EMAIL_TEMPLATES_PATH}/*.json")
for file_path in template_files:
with open(file_path) as template_file:
template_data = json.loads(template_file.read())["Template"]
ses.create_template(template_data)
yield ses
The create_template
call is mandatory in order for “sending” the emails, even in a mock environment.
Now let's think of an application code example that sends the email we just covered and that we want to test:
from emails.users import ForgotPasswordEmail
def reset_user_password(user):
# ...Some previous logic...
email = ForgotPasswordEmail(user)
email.send_email(to_addresses=[user.email])
Before we begin writing the test, let's create a helper that will help us assert that the email was indeed sent with the correct data:
# tests/helpers.py
import json
from moto import ses_backend
from emails.base import BaseEmail
def assert_email_sent(email_definition: BaseEmail, to_addresses: List[str]):
"""Asserts that an email was "sent" using a mocked Amazon SES client.
All the emails sent using the `ses` fixture in the entire fixture
scope will be available here. Therefore we iterate through all the
emails to find the one that matches the email definition passed.
"""
for message in ses_backend.sent_messages:
try:
assert message.template == [email_definition.get_ses_template_name()], "Sent email does not match template"
assert message.template_data == [
json.dumps(email_definition.get_template_data())
], "Sent email does not match template data"
assert message.destinations["BccAddresses"] == to_addresses, "Sent email does not match destination addresses"
except AssertionError:
pass
else:
# Matching email found
break
else:
raise AssertionError(f"Email {email_definition} was not sent")
A nice example for using a for-else. The ses_backend
provided by Moto stores all emails sent using Moto's SES mock client. Therefore we need to iterate through all these emails to find the one we actually want to assert.
Writing the test is now a piece of cake:
from somemodule import reset_user_password
from emails.users import ForgotPasswordEmail
from tests.helpers import assert_email_sent
def test_reset_user_password(ses, user):
# `ses` fixture needs to be requested so that SES is mocked, and
# the templates are created beforehand.
# Let's also assume that we have a `user` fixture to work with
reset_user_password(user)
# Some other possible assertions here
# Now let's assert that the email was "sent"
assert_email_sent(ForgotPasswordEmail(user), to_addresses=[user.email])
Thanks to the helper, the test will fail if:
- The email was not sent with the correct template data (constructed from argumens to
__init__
) passed in the test - The email was not sent to the addresses specified in the test