Blog

Ideas and insights from our team

Don't forget the stamps: testing email content in Django


When developing a web app how often do you check the emails you send are all working properly? Not as often as your web pages, right? That's ok, don't feel guilty, emails are hard to test and they are often someone's else responsibility to write and take care. This doesn't mean we should give up on them. There are some things we can do to prevent end up with broken emails.

Here at Vinta we normally use django-templated-email to handle email sending. It is a cool project started by Bradley Whittington to help writing emails using Django templates. It also allows you to have plain text and HTML versions of your emails in the same file. This means it's easier to write and maintain your emails (hooray!). Unfortunately Bradley didn't have the time to maintain it anymore so we offered ourselves to adopt it. We've been fixing bugs and making improvements for some time now so the project is healthy and back in activity.

Back to our initial problem now. There are two main things that I recommend you to test when working with emails: the content of your context variables and checking if you are actually passing a value to every variable in the email. I'll be using django-templated-email in the examples, but the concepts apply to other email engines as well.

For context, this is how you send an email using django-templated-email:

# views.py
from django.http import HttpResponse
from templated_email import send_templated_mail

def complete_signup(request):
    request.user.is_active = True
    request.user.save()
    send_templated_mail(
        template_name='welcome',
        from_email='from@example.com',
        recipient_list=[request.user.email],
        context={
            'username': request.user.username,
            'full_name': request.user.get_full_name(),
        },
    )
    return HttpResponse('Signup Completed!')
# templates/templated_email/welcom.email

{% block subject %}Welcome {{ full_name }}!{% endblock %}

{% block plain %}
Hi {{ full_name }}!
You have successfully completed the signup process. 
Access our site using your username: {{ username }}
{% endblock %}

{% block html %}
<h1>Hi {{full_name}}!</h1>
<p>You have successfully completed the signup process</p>
<p>Access our site using your username: {{ username }}</p>
{% endblock %}

Now, I recommend using the mock package to test the variables you are passing to the email context. If you are not used to mocking stuff when writing tests, it may look a little scary. Don't worry it is simply replacing the function you are mocking by a fake one which you can retrieve information about how it was accessed. This is how it will look like:

import mock
from django.test import TestCase
from django.contrib.auth.models import User

class EmailTests(TestCase):

    def setUp(self):
        self.user = User.objects.create(
            username='theusername', email='the@email.com',
            first_name='Filipe', last_name='Ximenes',
            password='1')
        self.client.login(email=user.email, password='1')

    @mock.patch('myapp.views.send_templated_mail')
    def test_email_context_variables(self, send_templated_mail):
        # assume '/complete-signup/' is the URL to our view 
        self.client.post('/complete-signup/') 

        kwargs = send_templated_mail.call_args[1]
        context = kwargs['context']

        self.assertEqual(context['username'], self.user.username)
        self.assertEqual(context['full_name'], self.user.get_full_name())

The second part is a little more tricky. Let's say we decide to rewrite the welcome email and print a new variable (eg.: {{ signup_date }}) in it but we forget to pass it on the context data. Our current tests won't pick this change and we will end with a broken email. To fix this we will use a feature from the Django template engine. Luckily enough django-templated-email also uses this engine to render emails.

According to the documentation when defining the TEMPLATES settings variable, you can pass a string_if_invalid parameter. Django will replace the value of any variable that is not in the context by the value passed to string_if_invalid.

As we say in Brazil, we are going to make a gambiarra so that whenever string_if_invalid is accessed it will raise an error.

Here is how it works:

from django.conf import settings

# Get a copy of the default TEMPLATES value
RAISE_EXCEPTION_TEMPLATES = copy.deepcopy(settings.TEMPLATES)

# this is where the magic happens
class InvalidVarException(str):

    def __new__(cls, *args, **kwargs):
        return super().__new__(cls, '%s')

    def __mod__(self, missing):
        try:
            missing_str = str(missing)
        except:
            missing_str = 'Failed to create string representation'
        raise Exception('Unknown template variable %r %s' % (missing, missing_str))

    def __contains__(self, search):
        return True

# set the value of string_if_invalid and template debug to True
RAISE_EXCEPTION_TEMPLATES[0]['OPTIONS']['string_if_invalid'] = InvalidVarException()
RAISE_EXCEPTION_TEMPLATES[0]['OPTIONS']['debug'] = True

And all you need to do in yours tests is run the code in the view. If your email has a variable hanging without a value it will break the test.

from django.test import override_settings

    @override_settings(TEMPLATES=RAISE_EXCEPTION_TEMPLATES)
    def test_passes_all_email_variables(self):
        self.client.post('/complete-signup/')

As a note, I do not recommend for you to try using this in anyway in your production code. Also, when using in tests, decorate a single test case, never set this to the whole test suit or even a whole test class.

This post is based on this Stackoverflow answer, let me know in the comments if you have a better approach to this problem, I'm happy to update the post and give appropriate credits.

More from Vinta
Check out our Lessons Learned page and subscribe to our newsletter
How I test my DRF serializers

About Filipe Ximenes

Bike enthusiast, software developer and former director at Python Brasil Association. Likes open source and how people interact in open source communities.

Comments