Clean up and document Anymail's test EmailBackend

* Change Anymail's test EmailBackend to collect sent messages in
  django.core.mail.outbox, same as Django's own locmem EmailBackend.
  (So Django's test runner will automatically clear accumulated mail
  between test cases.)

* Rename EmailMessage `test_response` attr to `anymail_test_response`
  to avoid conflicts, and record merged ESP send params in
  new `anymail_send_params` attr.

* Add docs

Closes #36.
This commit is contained in:
medmunds
2017-09-01 13:13:25 -07:00
parent a9c663f36a
commit 2faa5f96cb
6 changed files with 106 additions and 42 deletions

View File

@@ -1,37 +1,42 @@
from anymail.exceptions import AnymailAPIError from django.core import mail
from anymail.message import AnymailRecipientStatus
from .base import AnymailBaseBackend, BasePayload from .base import AnymailBaseBackend, BasePayload
from ..exceptions import AnymailAPIError
from ..message import AnymailRecipientStatus
from ..utils import get_anymail_setting from ..utils import get_anymail_setting
class EmailBackend(AnymailBaseBackend): class EmailBackend(AnymailBaseBackend):
""" """
Anymail backend that doesn't do anything. Anymail backend that simulates sending messages, useful for testing.
Used for testing Anymail common backend functionality. Sent messages are collected in django.core.mail.outbox (as with Django's locmem backend).
In addition:
* Anymail send params parsed from the message will be attached to the outbox message
as a dict in the attr `anymail_test_params`
* If the caller supplies an `anymail_test_response` attr on the message, that will be
used instead of the default "sent" response. It can be either an AnymailRecipientStatus
or an instance of AnymailAPIError (or a subclass) to raise an exception.
""" """
esp_name = "Test" esp_name = "Test"
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
# Init options from Django settings
esp_name = self.esp_name
self.sample_setting = get_anymail_setting('sample_setting', esp_name=esp_name,
kwargs=kwargs, allow_bare=True)
self.recorded_send_params = get_anymail_setting('recorded_send_params', default=[],
esp_name=esp_name, kwargs=kwargs)
super(EmailBackend, self).__init__(*args, **kwargs) super(EmailBackend, self).__init__(*args, **kwargs)
if not hasattr(mail, 'outbox'):
mail.outbox = [] # see django.core.mail.backends.locmem
def build_message_payload(self, message, defaults): def build_message_payload(self, message, defaults):
return TestPayload(backend=self, message=message, defaults=defaults) return TestPayload(backend=self, message=message, defaults=defaults)
def post_to_esp(self, payload, message): def post_to_esp(self, payload, message):
# Keep track of the send params (for test-case access) # Keep track of the sent messages and params (for test cases)
self.recorded_send_params.append(payload.params) message.anymail_test_params = payload.params
mail.outbox.append(message)
try: try:
# Tests can supply their own message.test_response: # Tests can supply their own message.test_response:
response = message.test_response response = message.anymail_test_response
if isinstance(response, AnymailAPIError): if isinstance(response, AnymailAPIError):
raise response raise response
except AttributeError: except AttributeError:
@@ -49,14 +54,6 @@ class EmailBackend(AnymailBaseBackend):
raise AnymailAPIError('Unparsable test response') raise AnymailAPIError('Unparsable test response')
# Pre-v0.8 naming (immediately deprecated for this undocumented test feature)
class TestBackend(object):
def __init__(self, **kwargs):
raise NotImplementedError(
"Anymail's (undocumented) TestBackend has been renamed to "
"'anymail.backends.test.EmailBackend'")
class TestPayload(BasePayload): class TestPayload(BasePayload):
# For test purposes, just keep a dict of the params we've received. # For test purposes, just keep a dict of the params we've received.
# (This approach is also useful for native API backends -- think of # (This approach is also useful for native API backends -- think of
@@ -129,3 +126,16 @@ class TestPayload(BasePayload):
def set_esp_extra(self, extra): def set_esp_extra(self, extra):
# Merge extra into params # Merge extra into params
self.params.update(extra) self.params.update(extra)
class _EmailBackendWithRequiredSetting(EmailBackend):
"""Test backend with a required setting `sample_setting`.
Intended only for internal use by Anymail settings tests.
"""
def __init__(self, *args, **kwargs):
esp_name = self.esp_name
self.sample_setting = get_anymail_setting('sample_setting', esp_name=esp_name,
kwargs=kwargs, allow_bare=True)
super(_EmailBackendWithRequiredSetting, self).__init__(*args, **kwargs)

View File

@@ -218,11 +218,13 @@ ESP send status
if you try to access it. if you try to access it.
This might cause problems in your test cases, because Django This might cause problems in your test cases, because Django
`substitutes its own locmem email backend`_ during testing (so anymail_status :ref:`substitutes its own locmem EmailBackend <django:topics-testing-email>`
won't be set even after sending). Your code should either guard against during testing (so anymail_status never gets attached to the EmailMessage).
a missing anymail_status attribute, or use :class:`AnymailMessage` If you run into this, you can: change your code to guard against
(or the :class:`AnymailMessageMixin`) which initializes its anymail_status a missing anymail_status attribute; switch from using EmailMessage to
attribute to a default AnymailStatus object. :class:`AnymailMessage` (or the :class:`AnymailMessageMixin`) to ensure the
anymail_status attribute is always there; or substitute
:ref:`Anymail's test backend <test-backend>` in any affected test cases.
After sending through an Anymail backend, After sending through an Anymail backend,
:attr:`~AnymailMessage.anymail_status` will be an object with these attributes: :attr:`~AnymailMessage.anymail_status` will be an object with these attributes:
@@ -321,10 +323,6 @@ ESP send status
message.anymail_status.esp_response.json() message.anymail_status.esp_response.json()
.. _substitutes its own locmem email backend:
https://docs.djangoproject.com/en/stable/topics/testing/tools/#email-services
.. _inline-images: .. _inline-images:
Inline images Inline images

View File

@@ -10,6 +10,7 @@ done with Anymail:
multiple_backends multiple_backends
django_templates django_templates
securing_webhooks securing_webhooks
test_backend
.. TODO: .. TODO:
.. Working with django-mailer(2) .. Working with django-mailer(2)

View File

@@ -0,0 +1,52 @@
.. _test-backend:
Testing your app
================
Django's own test runner makes sure your
:ref:`test cases don't send email <django:topics-testing-email>`,
by loading a dummy EmailBackend that accumulates messages
in memory rather than sending them. That works just fine with Anymail.
Anymail also includes its own "test" EmailBackend. This is intended primarily for
Anymail's own internal tests, but you may find it useful for some of your test cases, too:
* Like Django's locmem EmailBackend, Anymail's test EmailBackend collects sent messages
in :data:`django.core.mail.outbox`.
Django clears the outbox automatically between test cases.
See :ref:`email testing tools <django:topics-testing-email>` in the Django docs for more information.
* Unlike the locmem backend, Anymail's test backend processes the messages as though they
would be sent by a generic ESP. This means every sent EmailMessage will end up with an
:attr:`~anymail.message.AnymailMessage.anymail_status` attribute after sending,
and some common problems like malformed addresses may be detected.
(But no ESP-specific checks are run.)
* Anymail's test backend also adds an :attr:`anymail_send_params` attribute to each EmailMessage
as it sends it. This is a dict of the actual params that would be used to send the message,
including both Anymail-specific attributes from the EmailMessage and options that would
come from Anymail settings defaults.
Here's an example:
.. code-block:: python
from django.core import mail
from django.test import TestCase
from django.test.utils import override_settings
@override_settings(EMAIL_BACKEND='anymail.backends.test.EmailBackend')
class SignupTestCase(TestCase):
# Assume our app has a signup view that accepts an email address...
def test_sends_confirmation_email(self):
self.client.post("/account/signup/", {"email": "user@example.com"})
# Test that one message was sent:
self.assertEqual(len(mail.outbox), 1)
# Verify attributes of the EmailMessage that was sent:
self.assertEqual(mail.outbox[0].to, ["user@example.com"])
self.assertEqual(mail.outbox[0].tags, ["confirmation"]) # an Anymail custom attr
# Or verify the Anymail params, including any merged settings defaults:
self.assertTrue(mail.outbox[0].anymail_send_params["track_clicks"])

View File

@@ -2,6 +2,7 @@ from datetime import datetime
from email.mime.text import MIMEText from email.mime.text import MIMEText
import six import six
from django.core import mail
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.core.mail import get_connection, send_mail from django.core.mail import get_connection, send_mail
from django.test import SimpleTestCase from django.test import SimpleTestCase
@@ -16,34 +17,36 @@ from anymail.message import AnymailMessage
from .utils import AnymailTestMixin from .utils import AnymailTestMixin
recorded_send_params = [] @override_settings(EMAIL_BACKEND='anymail.backends.test.EmailBackend')
@override_settings(EMAIL_BACKEND='anymail.backends.test.EmailBackend',
ANYMAIL_TEST_SAMPLE_SETTING='sample', # required test EmailBackend setting
ANYMAIL_TEST_RECORDED_SEND_PARAMS=recorded_send_params)
class TestBackendTestCase(SimpleTestCase, AnymailTestMixin): class TestBackendTestCase(SimpleTestCase, AnymailTestMixin):
"""Base TestCase using Anymail's Test EmailBackend""" """Base TestCase using Anymail's Test EmailBackend"""
def setUp(self): def setUp(self):
super(TestBackendTestCase, self).setUp() super(TestBackendTestCase, self).setUp()
del recorded_send_params[:] # empty the list from previous tests
# Simple message useful for many tests # Simple message useful for many tests
self.message = AnymailMessage('Subject', 'Text Body', 'from@example.com', ['to@example.com']) self.message = AnymailMessage('Subject', 'Text Body', 'from@example.com', ['to@example.com'])
@staticmethod @staticmethod
def get_send_count(): def get_send_count():
"""Returns number of times "send api" has been called this test""" """Returns number of times "send api" has been called this test"""
return len(recorded_send_params) try:
return len(mail.outbox)
except AttributeError:
return 0 # mail.outbox not initialized by either Anymail test or Django locmem backend
@staticmethod @staticmethod
def get_send_params(): def get_send_params():
"""Returns the params for the most recent "send api" call""" """Returns the params for the most recent "send api" call"""
return recorded_send_params[-1] try:
return mail.outbox[-1].anymail_test_params
except IndexError:
raise IndexError("No messages have been sent through the Anymail test backend")
except AttributeError:
raise AttributeError("The last message sent was not processed through the Anymail test backend")
@override_settings(EMAIL_BACKEND='anymail.backends.test.EmailBackend') # but no ANYMAIL settings overrides @override_settings(EMAIL_BACKEND='anymail.backends.test._EmailBackendWithRequiredSetting')
class BackendSettingsTests(SimpleTestCase, AnymailTestMixin): # (so not TestBackendTestCase) class BackendSettingsTests(TestBackendTestCase):
"""Test settings initializations for Anymail EmailBackends""" """Test settings initializations for Anymail EmailBackends"""
@override_settings(ANYMAIL={'TEST_SAMPLE_SETTING': 'setting_from_anymail_settings'}) @override_settings(ANYMAIL={'TEST_SAMPLE_SETTING': 'setting_from_anymail_settings'})

View File

@@ -100,7 +100,7 @@ class TestPostSendSignal(TestBackendTestCase):
self.receiver_called = True self.receiver_called = True
self.addCleanup(post_send.disconnect, receiver=handle_post_send) self.addCleanup(post_send.disconnect, receiver=handle_post_send)
self.message.test_response = { self.message.anymail_test_response = {
'recipient_status': { 'recipient_status': {
'to@example.com': AnymailRecipientStatus(message_id=None, status='rejected') 'to@example.com': AnymailRecipientStatus(message_id=None, status='rejected')
} }