Testing time-based views in Django

26 January 2009

django, testing

There are situations where real time is important in the behaviour of some views. For example, one could write an anti-spam mechanism to prevent robots or ill-intended users to post a form too often and too rapidly. In this article I'll present a strategy for testing such views.

Let's take a very simple example, in which we'll let users post messages. The time constraint we'll set is that users have to wait at least 15 minutes between 2 consecutive postings, otherwise their message will be ignored. I'll start by giving out the code. First, the model:

from django.db import models
from django.contrib.auth.models import User

class Message(models.Model):
    user = models.ForeignKey(User, related_name='messages')
    datetime = models.DateTimeField()
    message = models.CharField(max_length=100)

    def __unicode__(self):
        return self.message

Nothing complicated here. Just a simple model to record the user and the time at which the message is posted.

Then, we will use a setting to determine the interval of time a user should wait before posting another message (You will see later why using a setting is crucial here). In our example we pick 15 minutes. So, add the following to your settings.py file:

MESSAGE_INTERVAL = 60 * 15 # 15 minutes expressed in seconds

Now, on to the view:

from datetime import datetime, timedelta

from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.http import HttpResponse

from models import Message

def send_message(request):
    message_interval = timedelta(seconds=settings.MESSAGE_INTERVAL)
    now = datetime.now()
        Message.objects.get(user=request.user, datetime__gte=now - message_interval)
        # A message has been posted too recently, so ask the user to wait a bit
        return HttpResponse('Be patient, try again later.')
    except ObjectDoesNotExist:
        # The user waited long enough, so we can create the message
        Message.objects.create(user=request.user, message=request.POST['message'], datetime=now)
        return HttpResponse('Thanks for your message.')

It's a pretty simple view, but let me just explain what's going on in there. First, we retrieve the time interval from the settings (it should be 15 minutes, or whatever value you've set). Then we check if a message has been posted by the same user during the last 15 minutes. If there is one, return an error notice telling the user he needs to wait; otherwise save the message and return a success notice.

All good! Now, how do we go about testing this? I'll start by giving out the code:

from time import sleep

from django.test import TestCase
from django.conf import settings
from django.contrib.auth.models import User

from models import Message

message_interval = 1 # seconds
not_long_enough = 0.7 # seconds
long_enough = 1.3 # seconds

class TimeBasedTesting(TestCase):

    def setUp(self):
        self.old_MESSAGE_INTERVAL = settings.MESSAGE_INTERVAL
        settings.MESSAGE_INTERVAL = message_interval
        User.objects.create_user('testuser', 'testuser@example.com', 'testpw')

    def tearDown(self):
        settings.MESSAGE_INTERVAL = self.old_MESSAGE_INTERVAL

    def test_message(self):
        self.client.login(username='testuser', password='testpw')

        # First message
        response = self.client.post('/send_message/', { 'message': 'First try!' })
        self.assertEquals(response.content, 'Thanks for your message.')

        # Wait enough
        response = self.client.post('/send_message/', { 'message': 'Second try!' })
        self.assertEquals(response.content, 'Thanks for your message.')

        # Don't wait enough
        response = self.client.post('/send_message/', { 'message': 'Third try!' })
        self.assertEquals(response.content, 'Be patient, try again later.')

        # Wait enough
        response = self.client.post('/send_message/', { 'message': 'Fourth try!' })
        self.assertEquals(response.content, 'Thanks for your message.')

        # Check what messages have been recorded
        self.assertEquals(str(Message.objects.all()), '[<Message: First try!>, <Message: Second try!>, <Message: Fourth try!>]')

Something we cannot afford is wait for 15 minutes to run each test. This is exactly why we put the waiting time interval into a setting -- because then we can override it in our tests. The setUp() method first makes a backup of the setting (stored in old_MESSAGE_INTERVAL) and then overrides it before the tests are run. The tearDown() method eventually restores the setting when the tests are complete so there's no risk to interfere with other applications or with other tests.

In this example we have set the time interval message_interval to 1 second, and we use two other variables to simulate whether the user waits long enough or not long enough between each posting. Then, to simulate the user waiting we simply use the built-in python function sleep(). It's as simple as that! This test should take approximately 5 seconds to run.

Finally, there's no problem if for some reasons you do not want to have the time interval in your settings. In fact, you can put it anywhere you like. The key is to move it to a variable out of your view so it can easily be overridden by your tests.

Hope it helps!