Testing your first Django app

This is my unofficial part five for the Django tutorial. I've attempted to write this in a similar style to the Django documentation and hopefully this will be useful for those looking for the next step after the tutorial or trying out testing with django for the first time. Testing is something I think we can all do better, I certainly know I could do better testing my code sometimes.

If you don't have the polls app from the end of part four and don't want to do it again you can grab it from my github. The code added in this tutorial is also available in the master branch of the repository.

This tutorial begins where Tutorial 4 left off. We're continuing the Web-poll application and will focus on testing our application and proving that it works as expected.

The testing environment

Let's first look at how you run the tests, make sure you are in the mysite directory and run the command python manage.py test. You will see output similar to this:

Creating test database...
Creating table auth_permission
Creating table auth_group
Creating table auth_user
Creating table auth_message
Creating table django_admin_log
Creating table django_content_type
Creating table django_session
Creating table django_site
Creating table polls_poll
Creating table polls_choice
Installing index for auth.Permission model
Installing index for auth.Message model
Installing index for admin.LogEntry model
Installing index for polls.Choice model
Ran 35 tests in 0.565s

Destroying test database...

You have successfully ran the test suite for the project. If your output doesn't look like this and it appears some of the tests have failed you may want to take a moment and go back to make sure your project matches the end of part 4.

When you run the test suite Django creates a new test database, synchronises your applications and loads any fixtures into the database. Each test is then executed in turn wrapped inside a database transaction so it can be rolled back after each test is completed. At the end of the tests Django destroys the test database for you.

What did we just test? manage.py test runs all the tests for each of the applications in your INSTALLED_APPS setting. All of the django.contrib applications ship with tests, as should all of yours. This is great as we can use this to test that the contrib applications are all configured correctly.

Run the same command again but include the verbose flag so we can see more information about what actually just happened. To do this run the command python manage.py test -v 2. There will be more output this time showing each of the tests that were carried out with the test outcome.

What is a test?

With Django (and Python in general) there are two main ways to write tests for your projects test suite; doctests and unit tests. In Django both of these use the standard python modules doctest and unittest. Doctests are written in Python docstrings and unit test are defined with classes. Good generic examples and explanations can be found on both of these in the Django documentation here and here also. If your unsure which to use, read this.

For the sake of being succinct we will focus on unit tests in this tutorial.

Writing a test

In part one it was noted in passing that when you created your polls application the file tests.py was created. The more adventurous of you will have taken a look and seen a simple (but redundant) example unit test and doctest. Django's test runner by default runs any tests that you create in tests.py in the application package. This is similar to the auto detection of admin configurations in the admin.py files.

To run only the tests for a specific application run the following command python manage.py test polls. This will run the two default tests that are present in the polls application we created earlier in the tutorial.

Delete the contents of the tests.py file and add the following test case.

from datetime import datetime

from django.test import TestCase

from mysite.polls.models import Poll

class PollTest(TestCase):

    def setUp(self):
        question="What is your favourite colour?"
        now = datetime.now()
        self.poll = Poll.objects.create(question=question, pub_date=now)
        self.poll.choice_set.create(choice="Red", votes=0)
        self.poll.choice_set.create(choice="Bue", votes=0)
        self.poll.choice_set.create(choice="Green", votes=0)

    def test_models(self):
        self.assertEqual(self.poll.choice_set.all().count(), 3)

In this test case the setUp method creates a new poll and adds three choices to the poll. Remember since the test runner creates its own database there is no data yet. setUp is called at the start of each test defined within your test case class. The test verifies that the number of choices in the created poll is equal to 3. A test is a method that starts with test_ and is a property of a class extending TestCase.

Try playing with this and making the test fail by changing the number or adding/removing choices. The test can also end with an error result if there is an uncaught exception, this can be done by adding Poll.objects.get(pk=2) as there isn't a poll with that id. It's worth familiarising yourself with the different possible test results.

When running your the tests by running python manage.py test polls the output displays a single . for each test that passes. If a test fails a F will be displated and if there is an error E will be displayed. A failure is when one of the assertions fails and an error happens when there is an uncaught exception while running the test.

Let's add another test case that does something more useful.

# ...
from django.test import Client

from mysite.polls.models import Poll, Choice

class PollTest(TestCase):

    # ...

    def test_voting(self):
        c = Client()
        # Perform a vote on the poll by mocking a POST request.
        response = c.post('/polls/1/vote/', {'choice': '1',})
        # In the vote view we redirect the user, so check the
        # response status code is 302.
        self.assertEqual(response.status_code, 302)
        # Get the choice and check there is now one vote.
        choice = Choice.objects.get(pk=1)
        self.assertEqual(choice.votes, 1)

In this example we make use of the Django test client. Using the client we are able to simulate requests without the need for a server, rather the request object is mocked and the view is invoked with the mock request. In this test we create a POST request that mocks a vote on the poll and then checks both the status_code of the response (to check we have been redirected) and verifies the number of votes has increased.

Test driven development

Test driven development is the practice of writing tests that fail and show what the system should do and then write or change the code to make the test(s) pass.

Next we want to add some Ajax to our application, to do this we want to be able to call the vote with an Ajax request and recieve some simple information we can work with rather than the full html response. First we will write the test for how we want this to work. We want the system to return '1' on a valid vote and '0' on an invalid vote in the http response.

class PollTest(TestCase):

    # ...

    def test_ajax_vote(self):

        c = Client()

        # Extra parameters to make this a Ajax style request.
        kwargs = {'HTTP_X_REQUESTED_WITH':'XMLHttpRequest'}

        # A valid vote
        response = c.post('/polls/1/vote/', {'choice': '1',}, **kwargs)
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.content, '1')

        # A invalid vote - choice doesn't exist
        response = c.post('/polls/1/vote/', {'choice': '10',}, **kwargs)
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.content, '0')

        # An invalid vote - poll doesn't exist
        response = c.post('/polls/2/vote/', {'choice': '1',}, **kwargs)
        self.assertEqual(response.status_code, 404)

Add this to your tests and then run the test suite. You should get output similar to this;

FAIL: test_ajax_vote (mysite.polls.tests.PollTest)
Traceback (most recent call last):
  File "/mysite/polls/tests.py", line 41, in test_ajax_vote
    self.assertEqual(response.status_code, 200)
AssertionError: 302 != 200


We have not updated our view, so rather than returning something useful for the Ajax request the server has returned a redirect to the results page. This then means the test fails at the first hurdle when we check the response code.

Now all we need to do is update the code in the vote view to make the tests pass. Change your view so it matches the following.

def vote(request, poll_id):
    p = get_object_or_404(Poll, pk=poll_id)
        selected_choice = p.choice_set.get(pk=request.POST['choice'])
    except (KeyError, Choice.DoesNotExist):
        # bad vote, return '0'
        if request.is_ajax():
            return HttpResponse("0")
        # Redisplay the poll voting form.
        return render_to_response('polls/poll_detail.html', {
            'object': p,
            'error_message': "You didn't select a choice.",
        selected_choice.votes += 1
        # vote saved, return '1'
        if request.is_ajax():
            return HttpResponse("1")
        # Always return an HttpResponseRedirect after successfully dealing
        # with POST data. This prevents data from being posted twice if a
        # user hits the Back button.
        return HttpResponseRedirect(reverse('poll_results', args=(p.id,)))

Re-run the test suite and it should now pass. You have successfully written your first tests for the polls app and even done a little bit of test driven development.

What next and further reading

Now you should have a good understanding of testing basics and hopefully a better idea of how to test your applications. Testing is really quite straight forward much of the time, you simply write a bit more code to make sure the code you have actually works - it's invaluable when you get into the routine and have a comprehensive test suite.

Here are a few resources that you should look at for taking the next step.

Finally, leaving you with this message:

"Code without tests is broken as designed" - Jacob Kaplan-Moss

Thanks for reading. You should follow me on Twitter.

Do you have any feedback or comments? The best place for discussion is on Reddit or Hacker News. Otherwise, email me.