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:

python pytest amazon ses

Comments

comments powered by Disqus