jeanphix

Django real world functional testing

20 Jan 2012

In the real world, a django application may be deployed on a real production plateform with as many layers as needed:

When we write functional tests with the dummy web client, we do not cover what’s going on over all those layers that composed application globality.

Also, in the real world, we use web client intances such as browsers like chromium, opera, IE… to deal with the application over HTTP and we want our application to run correctly on all those clients.

Another important point is the use of client side javascripts: when we use the dummy client, we can’t test javascripts.

That’s why we need to write functionnal tests that cover real world constraints.

Django 1.4 will bring us tools to do the job.

Lets write a demo application

To simply illustrate this, I’ve made a simple (useless) application composed of a single user story:

As an anonymous user, I can write a message, then the message is displayed in a list with the nine latest messages.

models.py:

from django.db import models
from django.core.urlresolvers import reverse


class Post(models.Model):
    message = models.CharField(max_length=140)
    author = models.CharField(max_length=50)
    created_at = models.DateTimeField(auto_now=True, auto_now_add=True,
        db_index=True)

    def get_absolute_url(self):
        return "%s#%s" % (reverse('home'), str(self.id))

views.py:

from django.views.generic.edit import CreateView
from models import Post
from settings import LATEST_POSTS_COUNT


class HomeView(CreateView):
    model = Post
    template_name = 'home.html'

    def get_context_data(self, **kwargs):
        context = super(HomeView, self).get_context_data(**kwargs)
        context['posts'] = Post.objects\
            .order_by('-created_at')[:LATEST_POSTS_COUNT]
        return context

Ok, now, I’ll write a functional test case basically using the dummy client:

from django.core.urlresolvers import reverse
from django.test import TestCase
from models import Post


class HomeTest(TestCase):
    def test_post_submission(self):
        initial_posts_count = Post.objects.count()
        r = self.client.post(reverse('home'), {
            'message': u'Hello world!',
            'author': u'jeanphix',
        }, follow=True)
        self.assertContains(r, '0 minutes ago')
        self.assertContains(r, 'Hello world!')
        self.assertEqual(initial_posts_count + 1, Post.objects.count())

Ok, the application now works correctly, but I think it’s gonna be better to do the job ansynchronous. So, let’s add a piece of javascript:

window.addEvent('domready', function() {

    var form = document.id('message-form');
    form.addEvent('submit', function(e) {

        var request = new Request.JSON({
            url: form.action,

            onSuccess: function(datas) {
                form.getElements('.errorlist').each(function(element) {
                    element.destroy();
                });
                if (datas.errors) {
                    // Form is not valid
                    Object.each(datas.errors, function(value, key) {
                        var input = document.id('id_' + key);
                        var previous = input.getParent('p').getPrevious();
                        var errorlist = new Element('ul', {
                            'class': 'errorlist',
                            html: '<li>' + value[0] + '</li>'
                        }).inject(input.getParent('p'), 'before');
                    });
                } else {
                    // New post has been created
                    var postList = document.id('post-list');
                    if (!postList) {
                        postList = new Element('ul', {'class': 'post-list'}).inject(form, 'after');
                    }
                    var li = new Element('li', {
                        id: datas.id,
                        html: '0 minutes ago ' +
                        '<span class="author">' + datas.author + '</span> wrote:' +
                        '<blockquote>' + datas.message + '</blockquote>'
                    }).inject(postList, 'top');
                    form.getElement('textarea').set('value', '').focus();
                }
            }
        }).post(form);
        e.stop();
    });
});

and then modify views.py to feet my need:

from django.views.generic.edit import CreateView
from django.http import HttpResponse
from django.utils import simplejson as json
from models import Post
from settings import LATEST_POSTS_COUNT


class HomeView(CreateView):
    model = Post
    template_name = 'home.html'

    def get_context_data(self, **kwargs):
        context = super(HomeView, self).get_context_data(**kwargs)
        context['posts'] = Post.objects\
            .order_by('-created_at')[:LATEST_POSTS_COUNT]
        return context

    def form_valid(self, form):
        resp = super(HomeView, self).form_valid(form)
        if self.request.is_ajax():
            datas = {'id': self.object.id, 'message': self.object.message,
                'author': self.object.author}
            return self.get_json_response(datas)
        else:
            return resp

    def form_invalid(self, form):
        if self.request.is_ajax():
            errors = {'errors': dict(form.errors)}
            return self.get_json_response(errors)
        else:
            return super(HomeView, self).form_invalid(form)

    def get_json_response(self, datas):
        return HttpResponse(json.dumps(datas), content_type='application/json')

Now my application works correctly even when javascript is disabled. Now it’s gonna be cool to write a functionnal test to cover that.

LiveServerTestCase

Django 1.4 introduced a new test class called LiveServerTestCase that extends TransactionTestCase.

This new test class launches the application in a real WSGI server instance into an asynchronous thread. As it’s a real server, we can easily write tests within real web browser instances.

There are many solutions providing python backends (Ghost.py, Selenium…), here I’m gonna use Selenium.

So, I had a new (minimalist) test case:

from django.test import LiveServerTestCase
from models import Post

from selenium.webdriver.firefox.webdriver import WebDriver
from selenium.webdriver.support.ui import WebDriverWait


class BaseLiveTest(LiveServerTestCase):
    @classmethod
    def setUpClass(cls):
        cls.selenium = WebDriver()
        super(BaseLiveTest, cls).setUpClass()

    @classmethod
    def tearDownClass(cls):
        super(BaseLiveTest, cls).tearDownClass()
        cls.selenium.quit()


class LiveHomeTest(BaseLiveTest):
    def test_post_submission(self):
        initial_posts_count = Post.objects.count()
        self.selenium.get(self.live_server_url)
        message = self.selenium.find_element_by_name("message")
        message.send_keys('my message')
        self.selenium.find_element_by_name("author").send_keys('jeanphix')
        self.selenium.find_element_by_xpath('//input[@value="Send"]').click()
        WebDriverWait(self.selenium, 10).until(
            lambda x: self.selenium.find_element_by_css_selector('ul li'))
        self.assertEqual(initial_posts_count + 1, Post.objects.count())

As you can see, we can deal with database and browser in the same script…

That’s all for now.