Tips for Testing Django Views

Views are probably the most essential part of a Django application. They provide an interface to your application for users or other external applications. It only makes sense that these need to be well-tested. At first, it can seem difficult and/or tedious to write unit tests for them. However, by decoupling your business logic from your views, testing business logic and view logic separately, and tracking code coverage, you can be confident in the reliability and accuracy of your application’s views.

Decouple Views and Business Logic

The first step for testing your Django views is to construct them in such a way that they are easy to test. Many applications have business logic intertwined with view logic such as parameter validation and response construction. This is less than optimal for the following reasons:

  • True unit tests can’t be written. Any unit tests written will essentially be integration tests; testing the view and business logic at the same time.
  • Tests will be more tedious to read and write, and will be redundant.
  • Logic embedded in views can’t be shared with other views or parts of your application.

You should think about a view as a proxy that calls on the business logic of your application on behalf of the user; not as the business logic itself.

Take this simple view that returns the sum of two numbers as a (bad) example:

from django import forms
from django.http import JsonResponse, HttpResponseBadRequest
from django.views.generic import View


class AddTwoNumbersForm(forms.Form):
    first = forms.DecimalField()
    second = forms.DecimalField()


class AddTwoNumbersView(View):
    def get(self, request):
        form = AddTwoNumbersForm(request.GET)
        if form.is_valid():
            params = form.cleaned_data
            result = params['first'] + params['second']
            return JsonResponse({'result': result})
        return HttpResponseBadRequest()

This is an oversimplified example, but there is only one line of business logic among several other lines of view logic:

result = params['first'] + params['second']

Let’s extract this line into its own function, so that it can be tested in isolation and will be able to be reused, if necessary. Ideally, your business logic should live in a different file from your views. For this example, let’s create a new directory called app at the same level as views.py. How you organize your business logic differs by the application, but for our purposes, let’s create a math.py file in our new app directory. The new function looks like this:

 def add_two_numbers(first, second):
    return first + second

It is also important to organize your business logic in a way that makes sense as your application grows. Use separate files and classes (and mixins) to your advantage. Organization is something that generally has to evolve over the lifespan of your application, so don’t worry too much about making it perfect all at once. Now, let’s update our view to make use of this new function.

class AddTwoNumbersView(View):
    def get(self, request):
        form = AddTwoNumbersForm(request.GET)
        if form.is_valid():
            params = form.cleaned_data
            result = math.add_two_numbers(params['first'], params['second'])
            return JsonResponse({'result': result})
        return HttpResponseBadRequest()

We now have a view with only view logic that calls business logic in another file. Next, we will look at how to write tests.

Test Business Logic

Now that our business logic is in its own file and function, we can easily write tests for it without dealing with request factories, mock clients, or other details of how a view works. Let’s create a tests folder at the same level as views.py (don’t forget to create an __init__.py file inside). Inside, let’s create a test_math.py that will contain tests only for math.py. Here are some example tests for add_two_numbers():

from decimal import Decimal

from django.test import TestCase
from ..app import math


class MathTests(TestCase):
    def test_add_two_numbers(self):
        self.assertEqual(math.add_two_numbers(1, 1), 2)

    def test_add_two_numbers_negative_result(self):
        self.assertEqual(math.add_two_numbers(1, -2), -1)

    def test_add_two_numbers_decimal(self):
        self.assertEqual(
            math.add_two_numbers(Decimal(1.25), Decimal(1.25)), 2.5)

    def test_add_two_numbers_float(self):
        self.assertEqual(
            math.add_two_numbers(1.25, 1.25), 2.5)

The tests here are straightforward and concise, as we don’t have to worry about view details such as parameter validation and URL routing. These tests also don’t need to change if they get called by something other than a view.

Test View Logic

Although the business logic is now well-tested, there is still plenty of view logic that is worth testing. Let’s create a tests/test_views.py file with the following tests:

from decimal import Decimal

from django.test import TestCase
from django.urls import reverse


class AddTwoNumbersViewTests(TestCase):
    def test_integer_params(self):
        response = self.client.get(reverse('addtwonumbers:add'), {
            'first': 1,
            'second': 1,
        })
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.json(), {
            'result': '2',
        })

    def test_decimal_params(self):
        response = self.client.get(reverse('addtwonumbers:add'), {
            'first': Decimal(1.25),
            'second': Decimal(1.25),
        })
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.json(), {
            'result': '2.50',
        })

    def test_float_params(self):
        response = self.client.get(reverse('addtwonumbers:add'), {
            'first': 1.25,
            'second': 1.25,
        })
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.json(), {
            'result': '2.50',
        })

    def test_params_invalid_type(self):
        response = self.client.get(reverse('addtwonumbers:add'), {
            'first': 'test',
            'second': 1,
        })
        self.assertEqual(response.status_code, 400)

    def test_missing_params(self):
        response = self.client.get(reverse('addtwonumbers:add'), {
            'first': 1,
        })
        self.assertEqual(response.status_code, 400)

Notice that these tests focus on request parameter validation, the content of a successful response, and the appropriate error response when the user gives invalid input. Some of these tests overlap with some of the business logic tests, as we need to enforce an expected response structure. That is a natural result of writing view tests. The difference is that we do not have to hit every edge case of the business logic; only of the view logic itself.

Measure Code Coverage

There are many measures you can use to attempt to determine how well-tested your application really is. One of the most common is code coverage. Although not a perfect measure, it is useful to see which lines of code or conditionals your tests are or are not hitting.

For Python, it is easy to check and keep track of your code coverage. First, pip install coverage. Next, run coverage run manage.py test && coverage html in your project’s root directory in order to measure your code coverage and create a set of HTML reports for you to look at.

After running these commands, you should see a set of HTML reports in an htmlcov directory. Inside, open up the index.html. You will see a long list of files, many of which are not even your code. You can create a configuration file to exclude these. For now, if we look only at the files that have logic we want to test, we should see the following:

math.py and views.py are covered at 100%! This tells us each line of code and conditional path in these files is being executed.

Closing Thoughts

Now that we have written thorough tests for our view and associated business logic, we can feel better about its accuracy and reliability. The ideal way to structure your views is to keep business logic separate and decoupled from it. This improves testing, code reusability, reduces complexity, and improves readability of your application.

Find the full example code on GitHub. To read more about Django views, see Django Function-Based Views vs. Class-Based Views.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.