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

Filipe Ximenes
February 6, 2017
<p>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.</p><p>Here at Vinta we normally use <a href="https://github.com/vintasoftware/django-templated-email">django-templated-email</a> to handle email sending. It is a cool project started by <a href="https://github.com/bradwhittington">Bradley Whittington</a> 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.</p><p>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.</p><p>For context, this is how you send an email using django-templated-email:</p><pre><code># 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!') </code></pre><pre><code># templates/templated_email/welcom.email {% block subject %}Welcome !{% endblock %} {% block plain %} Hi ! You have successfully completed the signup process. Access our site using your username: {% endblock %} {% block html %} &lt;h1&gt;Hi !&lt;/h1&gt; &lt;p&gt;You have successfully completed the signup process&lt;/p&gt; &lt;p&gt;Access our site using your username: &lt;/p&gt; {% endblock %} </code></pre><p>Now, I recommend using the <a href="https://docs.python.org/3/library/unittest.mock.html">mock</a> 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:</p><pre><code>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()) </code></pre><p>The second part is a little more tricky. Let's say we decide to rewrite the welcome email and print a new variable (eg.: <code></code>) 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.<br>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.</p><p>According to <a href="https://docs.djangoproject.com/en/1.10/ref/templates/api/#how-invalid-variables-are-handled">the documentation</a> when defining the <code>TEMPLATES</code> settings variable, you can pass a <code>string_if_invalid</code> parameter. Django will replace the value of any variable that is not in the context by the value passed to <code>string_if_invalid</code>.</p><p>As we say in Brazil, we are going to make a <em><a href="https://www.google.com.br/search?q=gambiarra&amp;source=lnms&amp;tbm=isch&amp;sa=X&amp;ved=0ahUKEwiWzYza3-zRAhUCFZAKHc9uCK8Q_AUICCgB&amp;biw=1229&amp;bih=598">gambiarra</a></em> so that whenever <code>string_if_invalid</code> is accessed it will raise an error.</p><p>Here is how it works:</p><pre><code>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 </code></pre><p>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.</p><pre><code>from django.test import override_settings @override_settings(TEMPLATES=RAISE_EXCEPTION_TEMPLATES) def test_passes_all_email_variables(self): self.client.post('/complete-signup/') </code></pre><p>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.</p><p>This post is based on <a href="http://stackoverflow.com/questions/15312135/how-to-make-django-template-raise-an-error-if-a-variable-is-missing-in-context">this Stackoverflow answer</a>, 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.</p><p>More from Vinta:</p><ul><li><a href="https://www.vinta.com.br/lessons-learned/">Check out our Lessons Learned page and subscribe to our newsletter</a>;</li><li><a href="https://www.vinta.com.br/blog/2017/how-i-test-my-drf-serializers/">How I test my DRF serializers</a>.</li></ul>