Patch Django EmailMultiAlternatives send() in a Celery Task so that an exception is raised
Question:
I want to test a Celery Task by raising an SMTPException when sending an email.
With the following code, located in:
my_app.mailer.tasks
from django.core.mail import EmailMultiAlternatives
@app.task(bind=True )
def send_mail(self):
subject, from_email, to = 'hello', '[email protected]', '[email protected]'
text_content = 'This is an important message.'
html_content = '<p>This is an <strong>important</strong> message.</p>'
msg = EmailMultiAlternatives(subject, text_content, from_email, [to])
msg.attach_alternative(html_content, "text/html")
try:
msg.send(fail_silently=False)
except SMTPException as exc:
print('Exception ', exc)
and then running the following test against it:
class SendMailTest(TestCase):
@patch('my_app.mailer.tasks.EmailMultiAlternatives.send')
def test_task_state(self, mock_send):
mock_send.side_effect = SMTPException()
task = send_mail.delay()
results = task.get()
self.assertEqual(task.state, 'SUCCESS')
The email is sent without error.
However, if I turn the task into a standard function (my_app.mailer.views) and then run the following test against it:
class SendMailTest(TestCase):
@patch('myapp.mailer.views.EmailMultiAlternatives.send')
def test_task_state(self, mock_send):
mock_send.side_effect = SMTPException()
send_mail(fail_silently=False)
The string ‘Exception’ is displayed, but there is no exc information as to what caused the exception.
Please, what am I doing wrong?
!!UPDATE!!
I now understand why no exc info was printed for the function version of the code. This can be achieved by changing;
mock_send.side_effect = SMTPException()
to;
mock_send.side_effect = Exception(SMTPException)
resulting in;
Exception <class 'smtplib.SMTPException'>
However, the issue of how to raise the same exception in the Celery Task in the first part of this post remains.
Answers:
Normally Celery task is being send to queue and run in separate process so you won’t see any output in your console. But you can use task_always_eager
to force celery task to be executed locally. Try to use override_settings
decorator for this:
from django.test import TestCase, override_settings
class SendMailTest(TestCase):
@override_settings(CELERY_TASK_ALWAYS_EAGER=True)
@patch('my_app.mailer.tasks.EmailMultiAlternatives.send')
def test_task_state(self, mock_send):
mock_send.side_effect = SMTPException()
task = send_mail.delay()
results = task.get()
self.assertEqual(task.state, 'SUCCESS')
A solution for testing a celery task is by utilising Celery Signatures.
This allows us to patch EmailMultiAlternatives.send, with a patch side_effect to raise an SMTPException.
It also allows us to assert that the required number of retries have been attempted.
@patch('my_app.mailer.tasks.EmailMultiAlternatives.send')
def test_smtp_exception(self, alt_send):
with self.assertLogs(logger='celery.app.trace') as cm:
alt_send.side_effect = SMTPException(SMTPException)
task = send_mail.s(kwargs=self.message).apply()
exc = cm.output
self.assertIn('Retry in 1s', exc[0])
self.assertIn('Retry in 2s', exc[1])
self.assertIn('Retry in 4s', exc[2])
self.assertIn('Retry in 8s', exc[3])
When run against
base_tasks.py
def backoff(attempts):
return 2 ** attempts
class BaseTaskEmail(app.Task):
abstract = True
def on_retry(self, exc, task_id, args, kwargs, einfo):
super(BaseTaskEmail, self).on_retry(exc, task_id, args, kwargs, einfo)
def on_failure(self, exc, task_id, args, kwargs, einfo):
super(BaseTaskEmail, self).on_failure(exc, task_id, args, kwargs, einfo)
my_app.mailer.tasks.py
@app.task(bind=True,
max_retries=4,
base=BaseTaskEmail,
)
def send_mail(self):
subject, from_email, to = 'hello', '[email protected]', '[email protected]'
text_content = 'This is an important message.'
html_content = '<p>This is an <strong>important</strong> message.</p>'
msg = EmailMultiAlternatives(subject, text_content, from_email, [to])
msg.attach_alternative(html_content, "text/html")
try:
msg.send(fail_silently=False)
except SMTPException as exc:
self.retry(countdown=backoff(self.request.retries), exc=exc)
I want to test a Celery Task by raising an SMTPException when sending an email.
With the following code, located in:
my_app.mailer.tasks
from django.core.mail import EmailMultiAlternatives
@app.task(bind=True )
def send_mail(self):
subject, from_email, to = 'hello', '[email protected]', '[email protected]'
text_content = 'This is an important message.'
html_content = '<p>This is an <strong>important</strong> message.</p>'
msg = EmailMultiAlternatives(subject, text_content, from_email, [to])
msg.attach_alternative(html_content, "text/html")
try:
msg.send(fail_silently=False)
except SMTPException as exc:
print('Exception ', exc)
and then running the following test against it:
class SendMailTest(TestCase):
@patch('my_app.mailer.tasks.EmailMultiAlternatives.send')
def test_task_state(self, mock_send):
mock_send.side_effect = SMTPException()
task = send_mail.delay()
results = task.get()
self.assertEqual(task.state, 'SUCCESS')
The email is sent without error.
However, if I turn the task into a standard function (my_app.mailer.views) and then run the following test against it:
class SendMailTest(TestCase):
@patch('myapp.mailer.views.EmailMultiAlternatives.send')
def test_task_state(self, mock_send):
mock_send.side_effect = SMTPException()
send_mail(fail_silently=False)
The string ‘Exception’ is displayed, but there is no exc information as to what caused the exception.
Please, what am I doing wrong?
!!UPDATE!!
I now understand why no exc info was printed for the function version of the code. This can be achieved by changing;
mock_send.side_effect = SMTPException()
to;
mock_send.side_effect = Exception(SMTPException)
resulting in;
Exception <class 'smtplib.SMTPException'>
However, the issue of how to raise the same exception in the Celery Task in the first part of this post remains.
Normally Celery task is being send to queue and run in separate process so you won’t see any output in your console. But you can use task_always_eager
to force celery task to be executed locally. Try to use override_settings
decorator for this:
from django.test import TestCase, override_settings
class SendMailTest(TestCase):
@override_settings(CELERY_TASK_ALWAYS_EAGER=True)
@patch('my_app.mailer.tasks.EmailMultiAlternatives.send')
def test_task_state(self, mock_send):
mock_send.side_effect = SMTPException()
task = send_mail.delay()
results = task.get()
self.assertEqual(task.state, 'SUCCESS')
A solution for testing a celery task is by utilising Celery Signatures.
This allows us to patch EmailMultiAlternatives.send, with a patch side_effect to raise an SMTPException.
It also allows us to assert that the required number of retries have been attempted.
@patch('my_app.mailer.tasks.EmailMultiAlternatives.send')
def test_smtp_exception(self, alt_send):
with self.assertLogs(logger='celery.app.trace') as cm:
alt_send.side_effect = SMTPException(SMTPException)
task = send_mail.s(kwargs=self.message).apply()
exc = cm.output
self.assertIn('Retry in 1s', exc[0])
self.assertIn('Retry in 2s', exc[1])
self.assertIn('Retry in 4s', exc[2])
self.assertIn('Retry in 8s', exc[3])
When run against
base_tasks.py
def backoff(attempts):
return 2 ** attempts
class BaseTaskEmail(app.Task):
abstract = True
def on_retry(self, exc, task_id, args, kwargs, einfo):
super(BaseTaskEmail, self).on_retry(exc, task_id, args, kwargs, einfo)
def on_failure(self, exc, task_id, args, kwargs, einfo):
super(BaseTaskEmail, self).on_failure(exc, task_id, args, kwargs, einfo)
my_app.mailer.tasks.py
@app.task(bind=True,
max_retries=4,
base=BaseTaskEmail,
)
def send_mail(self):
subject, from_email, to = 'hello', '[email protected]', '[email protected]'
text_content = 'This is an important message.'
html_content = '<p>This is an <strong>important</strong> message.</p>'
msg = EmailMultiAlternatives(subject, text_content, from_email, [to])
msg.attach_alternative(html_content, "text/html")
try:
msg.send(fail_silently=False)
except SMTPException as exc:
self.retry(countdown=backoff(self.request.retries), exc=exc)