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