diff --git a/anymail/backends/test.py b/anymail/backends/test.py index 0c3cbac..cc7c407 100644 --- a/anymail/backends/test.py +++ b/anymail/backends/test.py @@ -1,37 +1,42 @@ -from anymail.exceptions import AnymailAPIError -from anymail.message import AnymailRecipientStatus +from django.core import mail from .base import AnymailBaseBackend, BasePayload +from ..exceptions import AnymailAPIError +from ..message import AnymailRecipientStatus from ..utils import get_anymail_setting 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" 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) + if not hasattr(mail, 'outbox'): + mail.outbox = [] # see django.core.mail.backends.locmem def build_message_payload(self, message, defaults): return TestPayload(backend=self, message=message, defaults=defaults) def post_to_esp(self, payload, message): - # Keep track of the send params (for test-case access) - self.recorded_send_params.append(payload.params) + # Keep track of the sent messages and params (for test cases) + message.anymail_test_params = payload.params + mail.outbox.append(message) try: # Tests can supply their own message.test_response: - response = message.test_response + response = message.anymail_test_response if isinstance(response, AnymailAPIError): raise response except AttributeError: @@ -49,14 +54,6 @@ class EmailBackend(AnymailBaseBackend): 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): # For test purposes, just keep a dict of the params we've received. # (This approach is also useful for native API backends -- think of @@ -129,3 +126,16 @@ class TestPayload(BasePayload): def set_esp_extra(self, extra): # Merge extra into params 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) diff --git a/docs/sending/anymail_additions.rst b/docs/sending/anymail_additions.rst index 9baffd2..d4f5d86 100644 --- a/docs/sending/anymail_additions.rst +++ b/docs/sending/anymail_additions.rst @@ -218,11 +218,13 @@ ESP send status if you try to access it. This might cause problems in your test cases, because Django - `substitutes its own locmem email backend`_ during testing (so anymail_status - won't be set even after sending). Your code should either guard against - a missing anymail_status attribute, or use :class:`AnymailMessage` - (or the :class:`AnymailMessageMixin`) which initializes its anymail_status - attribute to a default AnymailStatus object. + :ref:`substitutes its own locmem EmailBackend ` + during testing (so anymail_status never gets attached to the EmailMessage). + If you run into this, you can: change your code to guard against + a missing anymail_status attribute; switch from using EmailMessage to + :class:`AnymailMessage` (or the :class:`AnymailMessageMixin`) to ensure the + anymail_status attribute is always there; or substitute + :ref:`Anymail's test backend ` in any affected test cases. After sending through an Anymail backend, :attr:`~AnymailMessage.anymail_status` will be an object with these attributes: @@ -321,10 +323,6 @@ ESP send status 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 diff --git a/docs/tips/index.rst b/docs/tips/index.rst index 1b13028..e97a53f 100644 --- a/docs/tips/index.rst +++ b/docs/tips/index.rst @@ -10,6 +10,7 @@ done with Anymail: multiple_backends django_templates securing_webhooks + test_backend .. TODO: .. Working with django-mailer(2) diff --git a/docs/tips/test_backend.rst b/docs/tips/test_backend.rst new file mode 100644 index 0000000..e4e679c --- /dev/null +++ b/docs/tips/test_backend.rst @@ -0,0 +1,52 @@ +.. _test-backend: + +Testing your app +================ + +Django's own test runner makes sure your +:ref:`test cases don't send 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 ` 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"]) diff --git a/tests/test_general_backend.py b/tests/test_general_backend.py index 6906581..8468de4 100644 --- a/tests/test_general_backend.py +++ b/tests/test_general_backend.py @@ -2,6 +2,7 @@ from datetime import datetime from email.mime.text import MIMEText import six +from django.core import mail from django.core.exceptions import ImproperlyConfigured from django.core.mail import get_connection, send_mail from django.test import SimpleTestCase @@ -16,34 +17,36 @@ from anymail.message import AnymailMessage from .utils import AnymailTestMixin -recorded_send_params = [] - - -@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) +@override_settings(EMAIL_BACKEND='anymail.backends.test.EmailBackend') class TestBackendTestCase(SimpleTestCase, AnymailTestMixin): """Base TestCase using Anymail's Test EmailBackend""" def setUp(self): super(TestBackendTestCase, self).setUp() - del recorded_send_params[:] # empty the list from previous tests # Simple message useful for many tests self.message = AnymailMessage('Subject', 'Text Body', 'from@example.com', ['to@example.com']) @staticmethod def get_send_count(): """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 def get_send_params(): """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 -class BackendSettingsTests(SimpleTestCase, AnymailTestMixin): # (so not TestBackendTestCase) +@override_settings(EMAIL_BACKEND='anymail.backends.test._EmailBackendWithRequiredSetting') +class BackendSettingsTests(TestBackendTestCase): """Test settings initializations for Anymail EmailBackends""" @override_settings(ANYMAIL={'TEST_SAMPLE_SETTING': 'setting_from_anymail_settings'}) diff --git a/tests/test_send_signals.py b/tests/test_send_signals.py index 3a77c08..de42177 100644 --- a/tests/test_send_signals.py +++ b/tests/test_send_signals.py @@ -100,7 +100,7 @@ class TestPostSendSignal(TestBackendTestCase): self.receiver_called = True self.addCleanup(post_send.disconnect, receiver=handle_post_send) - self.message.test_response = { + self.message.anymail_test_response = { 'recipient_status': { 'to@example.com': AnymailRecipientStatus(message_id=None, status='rejected') }