eat some code

Testing dates in Django

Testing based on dates & datetime

September 2016 #Mock  #Date  #Tests  #DateTime  #Django 

Django makes unit & functional testing easy (especially with WebTest). Tests on routing, permissions, database updates and emails are all straightforward to implement but how do you test dates & time? You might for example want to test regular email notifications.

1.Simply using "timedelta"

If you were to send reminders to do a "job" after two weeks, you would have to test the following;

  • a notification is sent for a (non-finished) job assigned 15 days ago
  • no notification is sent for a (non-finished) job assigned 13 days ago

These 2 tests can easily be written as follow:

from datetime import timedelta
from django.utils.timezone import now

def test_old_job_reminder_sent(self):
    job = JobFactory.create(assigned_at=now() - timedelta(days=15))
    send_job_reminders()
    self.assertEquals(len(mail.outbox), 1)

def test_recent_job_no_reminder_sent(self):
    job = JobFactory.create(assigned_at=now() - timedelta(days=13))
    send_job_reminders()
    self.assertEquals(len(mail.outbox), 0)

Notes:

2.Changing the current date (time)

The above solution works well when you don't have to worry about the values of now() or timezone_today(). Let's suppose you're working on an online calendar and you need to display how far we are in the month in an horizontal bar. For example, on the 14 of February, that bar would be half full (50%). The tests should basically check for the following:

  • current date=> expected output
  • 01/04/2016 => 3%
  • 15/04/2016 => 50%
  • 30/04/2016 => 100%

To be able to change the current date in our tests; the mock library is the perfect solution.

The actual implementation of day_of_month_percent is really simple and so are the tests:

from django import template
from django.utils.timezone import now

register = template.Library()


@register.simple_tag
def day_of_month_percent():
    return int(now().day * 100 / month_number_of_days())


def month_number_of_days():
    # ...
import mock
from django.test import TestCase
from datetime import datetime
from app.templatetags.my_tags import day_of_month_percent


class TranslationTemplateTagsTests(TestCase):
    _now_path = 'app.templatetags.my_tags.now'

    @mock.patch(_now_path, lambda: datetime(day=1, month=4, year=2016))
    def test_day_of_month_percent__0(self):
        self.assertEquals(day_of_month_percent(), 3)

    @mock.patch(_now_path, lambda: datetime(day=15, month=4, year=2016))
    def test_day_of_month_percent__1(self):
        self.assertEquals(day_of_month_percent(), 50)

    @mock.patch(_now_path, lambda: datetime(day=30, month=4, year=2016))
    def test_day_of_month_percent__2(self):
        self.assertEquals(day_of_month_percent(), 100)

It's really simple but you might have spotted the elephant in the room. We are not patching django.utils.timezone.now as you would expect. We are actually patching now where it's called by day_of_month_percent: app.templatetags.my_tags.now. Patching the former has no effect at all.

I remember facing that issue the first time I used mock in Python and I couldn't figure it out from the documentation. Python Mock Gotchas is an excellent article written by Alex Marandon on the topic.

3.Gotcha: auto_now / auto_now_add

My first example uses a regular datetime field called assigned_at. If you want to do something similar based on updated_at field, it won't work ! Let's go through it.

3.1.The model:

class Job(models.Model):
    updated_at = models.DateTimeField(auto_now=True)

3.2.The failing test:

def test_old_job_reminder_sent(self):
    job = JobFactory.create(updated_at=now() - timedelta(days=15))    # changed assigned_at to updated_at
    send_job_reminders()
    self.assertEquals(len(mail.outbox), 1)

The reason why this test will fail no matter what is that auto_now overwrite the value we give to updated_at. In other words, job.udpated_at will be "now" - not "now minus 15 days". The good news is that we've just discussed the solution !

3.3.Let's fix it with mock:

@mock.patch('django.utils.timezone.now', lambda: now() - timedelta(days=15))
def test_old_job_reminder_sent(self):
    job = JobFactory.create()    # this time updated_at will be 15 days ago
    send_job_reminders()
    self.assertEquals(len(mail.outbox), 1)

Image Credits

Beaker - by Kim Calandro via Free Images