From 469314e7bcad7390362804b73d74b1ff1ba6396b Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 8 May 2007 11:19:34 +0000 Subject: [PATCH] Added redirection for email services during test conditions. git-svn-id: http://code.djangoproject.com/svn/django/trunk@5173 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- django/test/testcases.py | 22 ++++---- django/test/utils.py | 32 +++++++++++- docs/testing.txt | 70 +++++++++++++++++++++++--- tests/modeltests/test_client/models.py | 34 +++++++++++++ tests/modeltests/test_client/urls.py | 4 +- tests/modeltests/test_client/views.py | 26 ++++++++++ 6 files changed, 169 insertions(+), 19 deletions(-) diff --git a/django/test/testcases.py b/django/test/testcases.py index 80f55b20d3..153931f42a 100644 --- a/django/test/testcases.py +++ b/django/test/testcases.py @@ -1,7 +1,7 @@ import re, doctest, unittest from urlparse import urlparse from django.db import transaction -from django.core import management +from django.core import management, mail from django.db.models import get_apps from django.test.client import Client @@ -33,23 +33,27 @@ class DocTestRunner(doctest.DocTestRunner): transaction.rollback_unless_managed() class TestCase(unittest.TestCase): - def install_fixtures(self): - """If the Test Case class has a 'fixtures' member, clear the database and - install the named fixtures at the start of each test. + def _pre_setup(self): + """Perform any pre-test setup. This includes: + * If the Test Case class has a 'fixtures' member, clearing the + database and installing the named fixtures at the start of each test. + * Clearing the mail test outbox. + """ management.flush(verbosity=0, interactive=False) if hasattr(self, 'fixtures'): management.load_data(self.fixtures, verbosity=0) - + mail.outbox = [] + def run(self, result=None): - """Wrapper around default run method so that user-defined Test Cases - automatically call install_fixtures without having to include a call to - super(). + """Wrapper around default run method to perform common Django test set up. + This means that user-defined Test Cases aren't required to include a call + to super().setUp(). """ self.client = Client() - self.install_fixtures() + self._pre_setup() super(TestCase, self).run(result) def assertRedirects(self, response, expected_path): diff --git a/django/test/utils.py b/django/test/utils.py index 9939e36fa4..aa2b8321be 100644 --- a/django/test/utils.py +++ b/django/test/utils.py @@ -1,7 +1,7 @@ import sys, time from django.conf import settings from django.db import connection, transaction, backend -from django.core import management +from django.core import management, mail from django.dispatch import dispatcher from django.test import signals from django.template import Template @@ -18,24 +18,54 @@ def instrumented_test_render(self, context): dispatcher.send(signal=signals.template_rendered, sender=self, template=self, context=context) return self.nodelist.render(context) +class TestSMTPConnection(object): + """A substitute SMTP connection for use during test sessions. + The test connection stores email messages in a dummy outbox, + rather than sending them out on the wire. + + """ + def __init__(*args, **kwargs): + pass + def open(self): + "Mock the SMTPConnection open() interface" + pass + def close(self): + "Mock the SMTPConnection close() interface" + pass + def send_messages(self, messages): + "Redirect messages to the dummy outbox" + mail.outbox.extend(messages) + def setup_test_environment(): """Perform any global pre-test setup. This involves: - Installing the instrumented test renderer + - Diverting the email sending functions to a test buffer """ Template.original_render = Template.render Template.render = instrumented_test_render + mail.original_SMTPConnection = mail.SMTPConnection + mail.SMTPConnection = TestSMTPConnection + + mail.outbox = [] + def teardown_test_environment(): """Perform any global post-test teardown. This involves: - Restoring the original test renderer + - Restoring the email sending functions """ Template.render = Template.original_render del Template.original_render + mail.SMTPConnection = mail.original_SMTPConnection + del mail.original_SMTPConnection + + del mail.outbox + def _set_autocommit(connection): "Make sure a connection is in autocommit mode." if hasattr(connection.connection, "autocommit"): diff --git a/docs/testing.txt b/docs/testing.txt index 2152cae98e..ba13dab67e 100644 --- a/docs/testing.txt +++ b/docs/testing.txt @@ -177,6 +177,7 @@ tools that can be used to establish tests and test conditions. * `Test Client`_ * `TestCase`_ +* `Email services`_ Test Client ----------- @@ -257,7 +258,7 @@ can be invoked on the ``Client`` instance. need to manually close the file after it has been provided to the POST. ``login(**credentials)`` - ** New in Django development version ** + **New in Django development version** On a production site, it is likely that some views will be protected from anonymous access through the use of the @login_required decorator, or some @@ -289,9 +290,9 @@ can be invoked on the ``Client`` instance. Testing Responses ~~~~~~~~~~~~~~~~~ -The ``get()``, ``post()`` and ``login()`` methods all return a Response -object. This Response object has the following properties that can be used -for testing purposes: +The ``get()`` and ``post()`` methods both return a Response object. This +Response object has the following properties that can be used for testing +purposes: =============== ========================================================== Property Description @@ -396,7 +397,7 @@ extra facilities. Default Test Client ~~~~~~~~~~~~~~~~~~~ -** New in Django development version ** +**New in Django development version** Every test case in a ``django.test.TestCase`` instance has access to an instance of a Django `Test Client`_. This Client can be accessed as @@ -453,9 +454,18 @@ This flush/load procedure is repeated for each test in the test case, so you can be certain that the outcome of a test will not be affected by another test, or the order of test execution. +Emptying the test outbox +~~~~~~~~~~~~~~~~~~~~~~~~ +**New in Django development version** + +At the start of each test case, in addition to installing fixtures, +Django clears the contents of the test email outbox. + +For more detail on email services during tests, see `Email services`_. + Assertions ~~~~~~~~~~ -** New in Django development version ** +**New in Django development version** Normal Python unit tests have a wide range of assertions, such as ``assertTrue`` and ``assertEquals`` that can be used to validate behavior. @@ -491,6 +501,49 @@ that can be useful in testing the behavior of web sites. Assert that the template with the given name was used in rendering the response. +Email services +-------------- +**New in Django development version** + +If your view makes use of the `Django email services`_, you don't really +want email to be sent every time you run a test using that view. + +When the Django test framework is initialized, it transparently replaces the +normal `SMTPConnection`_ class with a dummy implementation that redirects all +email to a dummy outbox. This outbox, stored as ``django.core.mail.outbox``, +is a simple list of all `EmailMessage`_ instances that have been sent. +For example, during test conditions, it would be possible to run the following +code:: + + from django.core import mail + + # Send message + mail.send_mail('Subject here', 'Here is the message.', 'from@example.com', + ['to@example.com'], fail_silently=False) + + # One message has been sent + self.assertEqual(len(mail.outbox), 1) + # Subject of first message is correct + self.assertEqual(mail.outbox[0].subject, 'Subject here') + +The ``mail.outbox`` object does not exist under normal execution conditions. +The outbox is created during test setup, along with the dummy `SMTPConnection`_. +When the test framework is torn down, the standard `SMTPConnection`_ class +is restored, and the test outbox is destroyed. + +As noted `previously`_, the test outbox is emptied at the start of every +test in a Django TestCase. To empty the outbox manually, assign the empty list +to mail.outbox:: + + from django.core import mail + + # Empty the test outbox + mail.outbox = [] + +.. _`Django email services`: ../email/ +.. _`SMTPConnection`: ../email/#the-emailmessage-and-smtpconnection-classes +.. _`EmailMessage`: ../email/#the-emailmessage-and-smtpconnection-classes +.. _`previously`: #emptying-the-test-outbox Running tests ============= @@ -610,11 +663,12 @@ a number of utility methods in the ``django.test.utils`` module. ``setup_test_environment()`` Performs any global pre-test setup, such as the installing the - instrumentation of the template rendering system. + instrumentation of the template rendering system and setting up + the dummy SMTPConnection. ``teardown_test_environment()`` Performs any global post-test teardown, such as removing the instrumentation - of the template rendering system. + of the template rendering system and restoring normal email services. ``create_test_db(verbosity=1, autoclobber=False)`` Creates a new test database, and run ``syncdb`` against it. diff --git a/tests/modeltests/test_client/models.py b/tests/modeltests/test_client/models.py index cd8dbe37d2..34242ee0d8 100644 --- a/tests/modeltests/test_client/models.py +++ b/tests/modeltests/test_client/models.py @@ -20,6 +20,7 @@ rather than the HTML rendered to the end-user. """ from django.test import Client, TestCase +from django.core import mail class ClientTest(TestCase): fixtures = ['testdata.json'] @@ -232,3 +233,36 @@ class ClientTest(TestCase): self.fail('Should raise an error') except KeyError: pass + + def test_mail_sending(self): + "Test that mail is redirected to a dummy outbox during test setup" + + response = self.client.get('/test_client/mail_sending_view/') + self.assertEqual(response.status_code, 200) + + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].subject, 'Test message') + self.assertEqual(mail.outbox[0].body, 'This is a test email') + self.assertEqual(mail.outbox[0].from_email, 'from@example.com') + self.assertEqual(mail.outbox[0].to[0], 'first@example.com') + self.assertEqual(mail.outbox[0].to[1], 'second@example.com') + + def test_mass_mail_sending(self): + "Test that mass mail is redirected to a dummy outbox during test setup" + + response = self.client.get('/test_client/mass_mail_sending_view/') + self.assertEqual(response.status_code, 200) + + self.assertEqual(len(mail.outbox), 2) + self.assertEqual(mail.outbox[0].subject, 'First Test message') + self.assertEqual(mail.outbox[0].body, 'This is the first test email') + self.assertEqual(mail.outbox[0].from_email, 'from@example.com') + self.assertEqual(mail.outbox[0].to[0], 'first@example.com') + self.assertEqual(mail.outbox[0].to[1], 'second@example.com') + + self.assertEqual(mail.outbox[1].subject, 'Second Test message') + self.assertEqual(mail.outbox[1].body, 'This is the second test email') + self.assertEqual(mail.outbox[1].from_email, 'from@example.com') + self.assertEqual(mail.outbox[1].to[0], 'second@example.com') + self.assertEqual(mail.outbox[1].to[1], 'third@example.com') + \ No newline at end of file diff --git a/tests/modeltests/test_client/urls.py b/tests/modeltests/test_client/urls.py index f63c486d01..52fc8fe692 100644 --- a/tests/modeltests/test_client/urls.py +++ b/tests/modeltests/test_client/urls.py @@ -11,5 +11,7 @@ urlpatterns = patterns('', (r'^form_view_with_template/$', views.form_view_with_template), (r'^login_protected_view/$', views.login_protected_view), (r'^session_view/$', views.session_view), - (r'^broken_view/$', views.broken_view) + (r'^broken_view/$', views.broken_view), + (r'^mail_sending_view/$', views.mail_sending_view), + (r'^mass_mail_sending_view/$', views.mass_mail_sending_view) ) diff --git a/tests/modeltests/test_client/views.py b/tests/modeltests/test_client/views.py index 3b7a57f4d0..18d6a2dcd9 100644 --- a/tests/modeltests/test_client/views.py +++ b/tests/modeltests/test_client/views.py @@ -1,4 +1,5 @@ from xml.dom.minidom import parseString +from django.core.mail import EmailMessage, SMTPConnection from django.template import Context, Template from django.http import HttpResponse, HttpResponseRedirect from django.contrib.auth.decorators import login_required @@ -124,3 +125,28 @@ def session_view(request): def broken_view(request): """A view which just raises an exception, simulating a broken view.""" raise KeyError("Oops! Looks like you wrote some bad code.") + +def mail_sending_view(request): + EmailMessage( + "Test message", + "This is a test email", + "from@example.com", + ['first@example.com', 'second@example.com']).send() + return HttpResponse("Mail sent") + +def mass_mail_sending_view(request): + m1 = EmailMessage( + 'First Test message', + 'This is the first test email', + 'from@example.com', + ['first@example.com', 'second@example.com']) + m2 = EmailMessage( + 'Second Test message', + 'This is the second test email', + 'from@example.com', + ['second@example.com', 'third@example.com']) + + c = SMTPConnection() + c.send_messages([m1,m2]) + + return HttpResponse("Mail sent")