Hi, I'm Harlin and welcome to my blog. I write about Python, Alfresco and other cheesy comestibles.

Python + Django How to Test Views

Today I wanted to go over how to use Django's web client testing features. It's a fairly simple thing to grasp how to use Python's unit testing to test functions and procedures but I am finding that a lot of Django beginners are not sure how they can test an actual web app's functionality. Sure, there are graphical tools that can be used to test a web app's presentation -- that is, how it is supposed to look and render -- but, beneath that, there is still the web app's functionality that can (and needs to be) tested with normal unit testing tools.

We'll make use of Django's TestCase and Client objects to show you how to test basic GET and POST functionality. But, first, we'll need to set up a simple Django web project. This project will allow a user to Add an Event and then see a list of Events after they're added them. Our testing will then test how it is expected to work.

For this project, I used Python 3.6.4. As always, I recommend using pyenv so that you can segment your virtual environments. Go to a directory where you want to set up this project and then in the terminal, issue the following commands:

# pyenv global 3.6.4
# pyenv virtualenv django_testing_web
# pyenv local django_testing_web

We'll need to install the Django package and nothing else as it has a lot of useful things that come with it.

# pip install django

Now, let's create our test_example projects and create a simple web application.

# django-admin startproject test_example
# cd test_example
# ./manage.py startapp web

Let's add 'web' application to test_example/settings.py:

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'web',
]

Next, we'll create our very simple Event model. It will have four fields:

  • title - The title of each event
  • start_time - The starting time for the event
  • end_time - The ending time for the event
  • description - A description of the event

In web/models.py add this model:

from django.db import models


class Event(models.Model):
    title = models.CharField('Title', max_length=100, unique=True)
    start_time = models.DateTimeField('Start')
    end_time = models.DateTimeField('End')
    description = models.TextField('Description')

    def __str__(self):
        return self.title

We'll need to persist the Event model to the built-in SQLite3 database that comes with Django. So, setting up a RDBMS database shouldn't be necessary to follow the examples:

# ./manage.py makemigrations
Migrations for 'web':
  web/migrations/0001_initial.py
    - Create model Event


# ./manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions, web
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying auth.0009_alter_user_last_name_max_length... OK
  Applying sessions.0001_initial... OK
  Applying web.0001_initial... OK

Now, we'll need a form to save our event. In web/forms.py add this form:

from django.forms import ModelForm
from .models import Event


class EventForm(ModelForm):
    class Meta:
        model = Event
        fields = ['title', 'start_time', 'end_time', 'description']

So that our application will render in the browser, we'll need to create two views:

  • add_event - Will allow us to save a new event
  • event_list - Will show the list of events that have been created

In web/views.py add these views:

from django.http import HttpResponseRedirect
from django.shortcuts import render, reverse
from .forms import EventForm
from .models import Event


def add_event(request):
    if request.method == 'POST':
        event_form = EventForm(request.POST)

        if event_form.is_valid:
            event_form.save()
            return HttpResponseRedirect(reverse('event_list'))

    else:
        event_form = EventForm()

    return render(
        request,
        'add_event.html',
        {
            'event_form': event_form,
        }
    )


def event_list(request):
    event_list = Event.objects.all()

    return render(
        request,
        'event_list.html',
        {
            'event_list': event_list,
        }
    )

Of course, we'll need some routings that Django knows which views to use for each URL. In test_example/urls.py add:

from django.contrib import admin
from django.urls import path
from web.views import add_event, event_list


urlpatterns = [
    path('admin/', admin.site.urls),
    path('event/add/', add_event, name='add_event'),
    path('events/', event_list, name='event_list'),
]

Before we forget, let's create our templates directory in the web application. This is where we will store our html templates to render the views:

# mkdir web/templates

Ok, now it's time create our html templates to handle the presentation. We'll create two of them of course, to correspond to each view we just created:

The add_event.html template will render our form we created earlier.

In web/templates/add_event.html add:

<!DOCTYPE html>
<html>
<head>
    <title>Add New Event</title>
</head>
<body>

    <h1>Add New Event</h1>

    <form action="" method="POST">
        {% csrf_token %}
        <table>
            {{ event_form.as_table }}
            <tr>
                <td>
                    <input type="submit" value="Save Event" />
                </td>
            </tr>
        </table>
    </form>
</body>
</html>

The event_list.html template will show our list of events.

In web/templates/event_list.html add:

<!DOCTYPE html>
<html>
<head>
    <title>Event List</title>
</head>
<body>

    <h1>Event List</h1>

    <p>
        <a href="{% url 'add_event' %}">
            Add New Event
        </a>
    </p>

    <table>
        {% for event in event_list %}
            <tr>
                <th>Title</th>
                <td>{{ event.title }}</td>
            </tr>
            <tr>
                <th>Start Time</th>
                <td>{{ event.start_time }}</td>
            </tr>
            <tr>
                <th>End Time</th>
                <td>{{ event.end_time }}</td>
            </tr>
            <tr>
                <th>Description</th>
                <td>{{ event.description }}</td>
            </tr>
            <tr>
                <td colspan="2"><hr></td>
            </tr>
        {% endfor %}
    </table>

</body>
</html>

Ok, time for our first moment of truth. Let's start up the Django webserver:

# ./manage.py runserver
Performing system checks...

System check identified no issues (0 silenced).
February 18, 2018 - 16:29:34
Django version 2.0.2, using settings 'test_example.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

Go to http://localhost:8000/event/add/

Fill in the title, start, end and description fields. In my case I used:

  • Title: Working on Django testing
  • Start Time: 2018-02-18 09:30
  • End Time: 2018-02-18 10:30
  • Description: Working on implementing Django tests for new web client.

Note that with Start Time and End Time and without a good datetime widget, your format needs to be: YYYY-MM-DD HH:mm else you will get a validation error.

After saving the event, you should be redirected to http://localhost:8000/events/ where you will see the Events List populated.

So, that is the extent of our application. But, now we'll get to the main point of this article which is how to do some basic GET/POST tests.

Our first test will make sure that we get a perfect 200 status code response when we first go to http://localhost:8000/events/.

Open web/tests.py and add:

from django.test import Client, TestCase


class EventTestCase(TestCase):
    def setUp(self):
        pass

    def test_for_200_response(self):
        c = Client()
        response = c.get('/events/')
        self.assertEqual(response.status_code, 200)

Now, to run the test:

# ./manage.py test web.tests
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.007s

OK
Destroying test database for alias 'default'...

Hopefully you got a 200 status code which means we were able to reach the /events/ URI as expected. Not really a big deal though because what if we did get a 200 status code returned but there was no content for some reason. Maybe our view was set up wrong and just didn't send us back anything content-wise. So, how can we test the content to make sure it renders as we expect it to?

Well, the response object also has another attribute called content which comes back to us as a byte-string response. We can test whether or not certain html components were included on the GET request. So, let's go ahead and test that out. Here's what a test like that would look like. Not e that our strings include the b''. If we just test unicode based strings, we'll get a nasty error. So, we'll avoid that by using the b'' strings.

Now, open web/tests.py and add:

from django.test import Client, TestCase


class EventTestCase(TestCase):
    def setUp(self):
        pass

    def test_for_200_response(self):
        c = Client()
        response = c.get('/events/')
        self.assertEqual(response.status_code, 200)

    def test_content(self):
        c = Client()
        response = c.get('/events/')
        self.assertTrue(b'<title>Event List</title>' in response.content)
        self.assertTrue(b'<h1>Event List</h1>' in response.content)
        self.assertTrue(b'Add New Event' in response.content)

# ./manage.py test web.tests
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..
----------------------------------------------------------------------
Ran 2 tests in 0.009s

OK
Destroying test database for alias 'default'...

Ok, so our test found that our <title> and <h1> elements were included in the page's content. That's good. We also saw that 'Add New Event' showed up in the content as well. And, we're doing this without any physical viewing confirmation.

So, we've tested our event_list view and template output. It was fairly basic. Now, let's test something a little more complex. Our add_event view involves us making changes to the app by adding a new event. But, first let's make sure that the add_event view returns a proper 200 status code.

Open web/tests.py and add another test:

from django.test import Client, TestCase


class EventTestCase(TestCase):
    def setUp(self):
        pass

    def test_for_200_response(self):
        c = Client()
        response = c.get('/events/')
        self.assertEqual(response.status_code, 200)

    def test_content(self):
        c = Client()
        response = c.get('/events/')
        self.assertTrue(b'<title>Event List</title>' in response.content)
        self.assertTrue(b'<h1>Event List</h1>' in response.content)
        self.assertTrue(b'Add New Event' in response.content)

    def test_for_200_response_add_event(self):
        c = Client()
        response = c.get('/event/add/')
        self.assertEqual(response.status_code, 200)
# ./manage.py test web.tests
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...
----------------------------------------------------------------------
Ran 3 tests in 0.018s

OK
Destroying test database for alias 'default'...

Ok, easy enough. Now, we want to test that we can do these things:

  1. We want to be able to successfully add a new event.
  2. We want to ensure the new event shows up in the /events/ URI that lists all of our saved events.

To add the new event in our test, we'll use client.post(). It's similar to client.get() except that we'll be sending some data in the form of a Python dictionary. The view will then save the data as a new row in the events table in our database. Note that these fields in the Python dictionary correspond to title, start_time, end_time and description that we have in our event form. Instead of expecting a 200 status code, this time, we'll expect a redirection back to /events/ instead. So, we'll test for a 302 status code instead.

Note also that our start_time and end_time expect a datetime object which we will provide.

Also, we'll check the /events/ URI again to make sure that what we added to the database shows up in the Event List.

Open web/tests.py and add another test:

import datetime
from django.test import Client, TestCase


class EventTestCase(TestCase):
    def setUp(self):
        pass

    def test_for_200_response(self):
        c = Client()
        response = c.get('/events/')
        self.assertEqual(response.status_code, 200)

    def test_content(self):
        c = Client()
        response = c.get('/events/')
        self.assertTrue(b'<title>Event List</title>' in response.content)
        self.assertTrue(b'<h1>Event List</h1>' in response.content)
        self.assertTrue(b'Add New Event' in response.content)

    def test_for_200_response_add_event(self):
        c = Client()
        response = c.get('/event/add/')
        self.assertEqual(response.status_code, 200)

    def test_post_add_event(self):
        c = Client()
        response = c.post(
            '/event/add/', {
                'title': 'A Test Event',
                'start_time': datetime.datetime(2018, 2, 18, 13, 30),
                'end_time': datetime.datetime(2018, 2, 18, 14, 30),
                'description': 'This is just a test event.',
            }
        )
        self.assertEqual(response.status_code, 302)

        response = c.get(
            '/events/',
        )
        self.assertEqual(response.status_code, 200)
        self.assertTrue(b'A Test Event' in response.content)
        self.assertTrue(b'Feb. 18, 2018, 1:30 p.m.' in response.content),
        self.assertTrue(b'Feb. 18, 2018, 2:30 p.m.' in response.content),
        self.assertTrue(b'This is just a test event.' in response.content)
# ./manage.py test web.tests
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
....
----------------------------------------------------------------------
Ran 4 tests in 0.023s

OK
Destroying test database for alias 'default'...

Ok, hopefully this worked for you and makes sense. Normally, we would also create another view called update_event which would allow a user to update a saved event to use different field values. And it probably wouldn't hurt to have a delete_view as well. You would still use client.post to test update_event and test the output in /events/ to make sure an event gets updated as expected but I will leave that up to you to try and see if you can figure it out. I promise you it won't be much different than testing add_event.

Still, what I've shown here in this post is about 80% of what you'll need to know to make basic web client tests in Django. Enjoy!

Any Comments, Always Welcome!