Improved Django Tests

You write tests. Or at least you should be writing tests! A Jacob always says, "Code without tests is broken as designed".

Unfortunately you have to write a ton of boilerplate code to test even the simplest of things. This isn't true of just Django, but most languages and frameworks. There are lots of tools like Factory Boy and pytest that help, but we can do better.

If you're looking for how to get started writing Django tests I'd suggest reading the excellent testing documentation first and also consider picking up Harry Percival's excellent book Test-Driven Development with Python

After the 400th time of copying over the same base test methods into various personal and client projects, I finally got around to packaging up and releasing all of our useful test additions into a module django-test-plus. I confess, I did this so I didn't have to copy it around anymore as much as wanting to share it with the world. My laziness is your gain. Come be lazy with me!

Let's have some testing fun

I think this module is best explained by examples. So let's just dive right in. First off you need to install django-test-plus with:

pip install django-test-plus

Then instead of this...

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

class MyViewTest(TestCase):

    def test_some_view(self):
        url = reverse('my-url-name')
        response = self.client.get(url)
        self.assertEqual(response.status_code, 200)

Do this...

from test_plus.test import TestCase

class MyViewTest(TestCase):

    def test_some_view(self):
        response = self.get('my-url-name')
        self.response_200(response)

For the sake of brevity, I'll be excluding the imports and test class in the rest of these examples and just focus on the methods themselves.

Or even this...

Getting a named URL and checking that status is 200 is such a common pattern, it has it's own helper. This will get the named URL, check that the status is 200 and return the response for other checks.

def test_some_view(self):
    response = self.get_check_200('my-url-name')

Instead of...

def test_with_reverse_args(self):
    url = reverse('some-url', kwargs={
        'year': 2015, 
        'slug': 'my-this-is-getting-long'
    })
    response = self.client.get(url)

Do this...

def test_with_reverse_args(self):
    response = self.get('some-url', year=2015, slug='much-better')

HTTP Posts work the same way

Instead of...

def post_with_reverse_args(self):
    url = reverse('some-url', kwargs={
        'year': 2015, 
        'slug': 'my-this-is-getting-long'
    })
    data = {'body': 'long-way'}
    response = self.client.post(url. data=data)

Save your keystrokes for another day with...

def test_post_better(self):
    response = self.post('some-url', 
        year=2015, 
        slug='much-better', 
        data={'body': 'lazy-way'}
    )

Sometimes you still need reverse...

So it's included for you. No need to import it yourself.

def test_reversing(self):
    response = self.get('my-named-url')
    test_url = self.reverse('some-other-url', pk=12)
    self.assertEqual(response.context['next'], test_url)

That's better, but that's still a bit rough on the hands. Let's make it a touch better with this version which uses our get_context() method:

def test_reversing(self):
    response = self.get('my-named-url')
    test_url = self.reverse('some-other-url', pk=12)
    self.assertEqual(self.get_context('next'), test_url)

We keep the last response received in self.last_response for you so there isn't a reason to have to pass it around all over the place.

Often you need to test the values of several context variables, so let's make that a bit easier.

def test_several_values(self):
    self.get('my-view')
    self.assertContext('key1', 'value1')
    self.assertContext('key2', 'value2')
    self.assertContext('key3', False)

What about other statuses?

Don't worry we've got you covered there are several response_XXX() methods to test for other common status code, for example:

def test_not_found(self):
    response = self.get('no-there')
    self.response_404(respones)

Authentication and Users

When testing out Django views you often need to make some users, login in as them, and poke around. Let's make that easier too!

def test_needs_login(self):
    # Make a user 
    user = self.make_user('testuser')
    
    # Make sure we protected the view
    self.assertLoginRequired('my-protected-view')

    with self.login(user):
          self.get('my-protected-view')
          self.assertContext('secret', True)

Performance

It's easy to make a few template changes that seem inconsequential only to have your database query count blow up to the size of Warren Buffett's checking account. Django provides the assertNumQueries assertion context to check the query count, but that is a static count. Often the results are slightly variable, so django-test-plus as assertNumQueriesLessThan.

def test_does_not_get_crazy(self):
    with self.assertNumQueriesLessThan(25):
        variable_query_count_function()

Ultimate Lazy

Maybe it's a toy project or you're adding tests to a project that previously didn't have any. Some tests are better than no tests right? So we provide a quick view check with assertGoodView:

def test_better_than_nothing(self):
    self.assertGoodView('first-view')
    self.assertGoodView('second-view')
    self.assertGoodView('something-else')

What's this do? It gets the view at the named URL, ensures the status code is 200 and tests that the number of queries run is less than 50. Oh and it returns the response if you want it for other purposes. Not amazing, but better than having no test coverage.

The future

I've been using various versions of this module for years, but there is always room for improvement. Happy to take pull requests submissions for new methods that will be generally useful. Or maybe one day I'll be less lazy and see about merging these into Django core.

Hope you like the module and find it useful. Happy Testing!

Tags: django, programming, python