Faster Django view unit tests with mocks

Unit tests need to be fast. Really fast. Some have suggested that speed is the defining characteristic of a unit test. But writing fast unit tests for Django views is hard.

The key to fast unit tests is isolation. You don't want to be hitting the database, making requests over the network, etc. You also want to minimize the amount of code that's running for each test. Ideally, you just want to run your code. But the default facility to testing views, the Django test client, is not isolated at all.

The test client is actually running a fully-featured Django server instance. Your requests go through the URL router, execute middleware, talk to the database and render HTML. It's slow, and it's more like an integration test. You certainly want to have something like that too, though I'm not convinced that the Django test client does a better job of it than Selenium.

So how do you write fast unit test for views? The first step is to isolate as much as you can. If you're like me, you probably don't want to hack up all your views to account to dependency injection. Luckily, in a dynamic language like Python, it's almost too easy to just mock all a view's dependencies on the fly.

from django.contrib.auth.decorators import login_required
from django.http import HttpResponseRedirect
from django.core.urlresolvers import reverse
from django.shortcuts import get_object_or_404
from django.contrib.auth.models import User
from django.shortcuts import render_to_response
from django.contrib import messages
from django.template.context import RequestContext


@login_required
def view_user(request, user_id):

    user = get_object_or_404(User, id=user_id)

    if request.user.is_authenticated() and request.user == user:
        messages.error(request, 'View your OWN record!')
        return HttpResponseRedirect(reverse('user_self'))

    return render_to_response('users/view.html',
        RequestContext(request, locals()))

Even for a simple view like this, how can we mock out the request, the database, the messages sub-system and the redirects?

import unittest
import factory
from mock import patch

from django.http import HttpRequest
from django.contrib.auth.models import User
from django.http import Http404

from website.views.blog import view_user


def setup(self):
    ''' using django-nose to only run ONCE, not once per test
    using globals for easy reference in the tests'''

    global request, user_being_viewed
    request = FakeRequestFactory()
    user_being_viewed = UserFactory()

    def render_to_response_echo(*args, **kwargs):
        ''' mocked render_to_response that just returns what was passed in,
        also puts the template name into the results dict '''
        context = args[1]
        locals = context.dicts[0]
        locals.update(dict(template_name=args[0]))
        return locals

    patch('website.views.blog.render_to_response',
        render_to_response_echo).start()


class ViewUserTestCase(unittest.TestCase):

    def test_404(self):
        with self.assertRaises(Http404):
            view_user(request, 1234)

    def test_user_found(self):
        self.assertEquals(user_being_viewed,
            view_user(request, user_being_viewed.id).get('user'))

    def test_authenticated_and_self(self):
        view_user(request, request.user.id)
        self.assertEquals('View your OWN record!',
            request._messages.pop)

    def test_authenticated_and_self_redirect(self):
        self.assertEquals('/home',
            view_user(request, request.user.id)['Location'])

    def test_user_found_template(self):
        self.assertEquals('users/view.html',
            view_user(request, user_being_viewed.id).get('template_name'))


class FakeMessages:
    ''' mocks the Django message framework, makes it easier to get
    the messages out '''

    messages = []

    def add(self, level, message, extra_tags):
        self.messages.append(str(message))

    @property
    def pop(self):
        return self.messages.pop()


def FakeRequestFactory(*args, **kwargs):
    ''' FakeRequestFactory, FakeMessages and FakeRequestContext are good for
    mocking out django views; they are MUCH faster than the Django test client.
    '''

    user = UserFactory()
    if kwargs.get('authenticated'):
        user.is_authenticated = lambda: True

    request = HttpRequest()
    request.user = user
    request._messages = FakeMessages()
    request.session = kwargs.get('session', {})
    if kwargs.get('POST'):
        request.method = 'POST'
        request.POST = kwargs.get('POST')
    else:
        request.method = 'GET'
        request.POST = kwargs.get('GET', {})

    return request


class UserFactory(factory.Factory):
    ''' using the excellent factory_boy library '''
    FACTORY_FOR = User
    username = factory.Sequence(lambda i: 'blogtest' + i)
    first_name = 'John'
    last_name = 'Doe'
    email = factory.Sequence(lambda i: 'blogtest%s@example.com' % i)

Notice that I am touching the database here. In my opinion, mocking out the database layer is silly; we already have an abstraction for that, it's called the ORM. Instead, you can get all the speed you need by using an in memory sqlite3 database for your unit tests. Just stick the following in your settings.py.

if 'test' in sys.argv:
    DATABASES = {
        'default': {
            'ENGINE': 'django.db.backends.sqlite3',
            'NAME': ':memory',
        },
    }

Also notice that I'm using Django nose. One of the best features of Nose is that it allows you to run the setup code as infrequently as you want. If you must, you can run it for every test. But it also let's you run it just once for a class or a package for speed. Any tweaks to need per-test can done with mocks and patches.

Running these five tests is pretty quick. It shouldn't get much slower as you go along, especially if you continue to re-use what few database record you do create.

$ ./manage.py test tests.test_blog
nosetests --verbosity 1 tests.test_blog --config=nose.cfg
Creating test database for alias 'default'...
.....
----------------------------------------------------------------------
Ran 5 tests in 0.356s

OK

Follow @chase_seibert on Twitter

Chase Seibert

Facts, hacks and attacks from my life as a web application developer

profile for Chase Seibert on Stack Exchange, a network of free, community-driven Q&A sites