Django Testing Tutorial

Testing is an important but often neglected part of any Django project. In this tutorial we'll review testing best practices and example code that can be applied to any Django app. I also cover testing in depth with many examples in my three books: Django for Beginners, Django for APIs, and Django for Professionals.

Broadly speaking there are two types of tests you need to run:

While we might we use a unit test to confirm that the homepage returns an HTTP status code of 200, an integration test could mimic the entire registration flow of a user.

For all tests the expectation is that the result is either expected, unexpected, or an error. An expected result would be an HTTP 200 response on the homepage, but we can--and should--also test that the homepage does not return something unexpected, like an HTTP 404 response. Anything else would be an error requiring further debugging.

The main focus of testing should be unit tests. You can't write too many of them. They are far easier to write, read, and debug than integration tests. They are also quite fast to run.

When to write/run tests

In the words of Django co-creator Jacob Kaplan-Moss, "Code without tests is broken as designed." You should write and run tests all the time. Whenever new code has been added to a project, it is not done until tests are added to confirm it works as intended and doesn't break other project code.

A beginner might think that writing tests takes too long but tests very quickly save huge amounts of time. Even for a solo developer, projects grow in size. Who knows if that new 3rd party integration works properly or subtly breaks an important part of your site? What if you step away from the code for a few weeks/months and can't remember everything? Or if a colleague needs to make a change?

Tests can be confusing to write initially--hence this tutorial--but practically speaking the same patterns are used over and over again.

It is common on many projects to rely on a continuous integration service to automatically run all existing tests on every new commit. This way you don't have to run tests manually.

Initial Set Up

Complete set up instructions can be found here for installing Python, Django, Git and the rest. In this tutorial we will use the standard Unix prompt of $ to precede all commands.

Assuming you already have Python installed and know how to use the command line, navigate to the Desktop and create a new directory called djangotesty.

# Windows
$ cd onedrive\desktop\code
$ mkdir djangotesty
$ cd djangotesty

# macOS
$ cd ~/desktop/code
$ mkdir djangotesty
$ cd djangotesty

We will create a new virtual environment called .venv and install Django. These instructions place it on the Desktop but any location you can easily access is fine.

# Windows
$ python -m venv .venv
$ .venv\Scripts\Activate.ps1
(.venv) $ python -m pip install django~=4.0.0

# macOS
$ python3 -m venv .venv
$ source .venv/bin/activate
(.venv) $ python3 -m pip install django~=4.0.0

Now create a new Django project called django_project and run migrate to set up the initial database.

(.venv) $ django-admin startproject django_project .
(.venv) $ python manage.py migrate

Run runserver now to confirm everything is installed properly. If you visit http://127.0.0.1:8000/ in your web browser you should see the following image:

Django Welcome Page

We will create two apps in this tutorial and test both thorougly: a pages app with two static pages and a messageboard app with a list view. A fuller description of creating these apps and Django itself can be found in my book Django for Beginners. Since the focus here is on testing, I will tersely give the commands to set up each now.

Hello, World and About Page

Let's create a basic home page and about page that we can then test. A fuller description of a Django Hello, World app can be found here. I will tersely give the commands here so we can focus on testing instead.

Create a new app called pages.

(.venv) $ python manage.py startapp pages

Add it to the INSTALLED_APPS configuration within django_project/settings.py.

# django_project/settings.py
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "pages.apps.PagesConfig",   # new
]

Ultimately we need dedicated views, urls, and templates for these two pages. We'll start with the views.

Update pages/views.py with two class-based views, HomePageView and AboutPageView, that rely on the corresponding templates home.html and about.html.

# pages/views.py
from django.views.generic import TemplateView


class HomePageView(TemplateView):
    template_name = "pages/home.html"


class AboutPageView(TemplateView):
    template_name = "pages/about.html"

Create a new file, pages/urls.py, for our two URL paths. We import both views at the top of the file, set them to "" and about/, and provide each with a URL name.

# pages/urls.py
from django.urls import path

from .views import HomePageView, AboutPageView

urlpatterns = [
    path("", HomePageView.as_view(), name="home"),
    path("about/", AboutPageView.as_view(), name="about"),
]

Update the django_project/urls.py file with our pages app URL paths.

# django_project/urls.py
from django.contrib import admin
from django.urls import path, include  # new

urlpatterns = [
    path("admin/", admin.site.urls),
    path("", include("pages.urls")),  # new
]

Lastly we need to create the template files for home.html and about.html referenced in pages/views.py. Django will automatically look for a templates/app_name directory within each app so create that now.

(.venv) $ mkdir pages/templates
(.venv) $ mkdir pages/templates/pages

Then in your text editor create pages/templates/pages/home.html and pages/templates/pages/about.html. Populate the templates with the following simple code.

<!-- pages/templates/pages/home.html -->
<h1>Homepage</h1>

<!-- pages/templates/pages/about.html -->
<h1>About page</h1>

Then navigate to the homepage at http://127.0.0.1:8000/ and about page at http://127.0.0.1:8000/about to confirm everything is working.

Homepage

About page

Time for tests.

Django Testing

Django comes with a small set of its own tools for writing tests, notably a test client and four provided test case classes. These classes rely on Python's unittest module and TestCase base class.

The Django test client can be used to act like a dummy web browser and check views. This can be done within the Django shell for one-off tests but is more commonly integrated into unit tests.

Most Django unit tests rely on TestCase but on occasions when an application does not rely on a database, SimpleTestCase can be used instead.

SimpleTestCase

Since our pages app only has two static pages at the moment we can use SimpleTestCase in the existing pages/tests.py file.

Take a look at the code below which adds four tests for our homepage. First, we test that the URL works correctly and the / web page, the homepage, returns an HTTP 200 Response. Second, we confirm calling the page by its URL name via reverse(). This is why we added name="home" to the pages/urls.py URL path for the homepage. Third, we confirm the template used is home.html. And finally we check the template's content looking for the HTML snippet <h1>Homepage</h1> and also that incorrect HTML is not on the page. It's always good to test both expected and unexpected behavior.

# pages/tests.py
from django.test import SimpleTestCase
from django.urls import reverse  


class HomepageTests(SimpleTestCase):
    def test_url_exists_at_correct_location(self):
        response = self.client.get("/")
        self.assertEqual(response.status_code, 200)

    def test_url_available_by_name(self):  
        response = self.client.get(reverse("home"))
        self.assertEqual(response.status_code, 200)

    def test_template_name_correct(self):  
        response = self.client.get(reverse("home"))
        self.assertTemplateUsed(response, "pages/home.html")

    def test_template_content(self):
        response = self.client.get(reverse("home"))
        self.assertContains(response, "<h1>Homepage</h1>")
        self.assertNotContains(response, "Not on the page")

Now run the tests. They should all pass.

(.venv) $ python manage.py test
Found 4 test(s).
System check identified no issues (0 silenced).
....
----------------------------------------------------------------------
Ran 4 tests in 0.007s

OK

As an exercise, see if you can add a class for AboutPageTests in this same file. It should have the same five tests but will need to be updated slightly. Run the test runner once complete. The correct code is below so try not to peak...

# pages/tests.py
from django.test import SimpleTestCase
from django.urls import reverse  


class HomepageTests(SimpleTestCase):
    def test_url_exists_at_correct_location(self):
        response = self.client.get("/")
        self.assertEqual(response.status_code, 200)

    def test_url_available_by_name(self):  
        response = self.client.get(reverse("home"))
        self.assertEqual(response.status_code, 200)

    def test_template_name_correct(self):  
        response = self.client.get(reverse("home"))
        self.assertTemplateUsed(response, "pages/home.html")

    def test_template_content(self):
        response = self.client.get(reverse("home"))
        self.assertContains(response, "<h1>Homepage</h1>")
        self.assertNotContains(response, "Not on the page")


class AboutPageTests(SimpleTestCase):  # new
    def test_url_exists_at_correct_location(self):
        response = self.client.get("/about/")
        self.assertEqual(response.status_code, 200)

    def test_url_available_by_name(self):  
        response = self.client.get(reverse("about"))
        self.assertEqual(response.status_code, 200)

    def test_template_name_correct(self):  
        response = self.client.get(reverse("about"))
        self.assertTemplateUsed(response, "pages/about.html")

    def test_template_content(self):
        response = self.client.get(reverse("about"))
        self.assertContains(response, "<h1>About page</h1>")
        self.assertNotContains(response, "Should not be here!")

Run the new tests now.

(.venv) $ python manage.py test
Found 8 test(s).
System check identified no issues (0 silenced).
........
----------------------------------------------------------------------
Ran 8 tests in 0.008s

OK

There are many more tests we could conceivably add but for static webpages these tests cover the basics: validating an HTTP 200 response, the correct view and template are used, and checking the template's content.

Message Board app

Now let's create our message board app so we can try testing out database queries. First create a new app called posts.

(.venv) $ python manage.py startapp posts

Add it to the django_project/settings.py file.

# django_project/settings.py
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "pages.apps.PagesConfig",
    "posts.apps.PostsConfig",  # new
]

Then run migrate to update the database.

(.venv) $ python manage.py migrate

Now add a basic model.

# posts/models.py
from django.db import models


class Post(models.Model):
    text = models.TextField()

    def __str__(self):
        """A string representation of the model."""
        return self.text

Create a database migration file and activate it.

(.venv) $ python  manage.py makemigrations
Migrations for 'posts':
  posts/migrations/0001_initial.py
    - Create model Post
(.venv) $ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, posts, sessions
Running migrations:
  Applying posts.0001_initial... OK

For simplicity we can just a post via the Django admin. So first create a superuser account and fill in all prompts.

(.venv) $ python manage.py createsuperuser

Update our admin.py file so the posts app is active in the Django admin.

# posts/admin.py
from django.contrib import admin

from .models import Post

admin.site.register(Post)

Then restart the Django server with python manage.py runserver and login to the Django admin at http://127.0.0.1:8000/admin/. You should see the admin’s login screen:

Admin page

Click on the link for + Add next to Posts. Enter in the simple text Hello world!.

Admin add post page

On "save" you'll see the following page.

Admin hello world page

Now add our posts/views.py file.

# posts/views.py
from django.views.generic import ListView

from .models import Post


class PostPageView(ListView):
    model = Post
    template_name = "posts/posts.html"

Create a posts/templates/posts/posts.html template file in your text editor and add the code below to simply output all posts in the database.

<!-- posts/templates/posts/posts.html -->
<h1>Message board homepage</h1>
<ul>
  {% for post in object_list %}
    <li>{{ post.text }}</li>
  {% endfor %}
</ul>

Finally, we need to update our urls.py files. Start with the project-level one located at django_project/urls.py.

# django_project/urls.py
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path("", include("pages.urls")),
    path("admin/", admin.site.urls),
    path("posts/", include("posts.urls")),
]

Then create a posts/urls.py file in your text editor and populate it as follows.

# posts/urls.py
from django.urls import path

from .views import PostPageView

urlpatterns = [
    path("", PostPageView.as_view(), name="posts"),
]

Okay, phew! We're done. Start up the local server python manage.py runserver and navigate to our new message board page at http://127.0.0.1:8000/posts.

Posts page

It simply displays our single post entry. Time for tests!

TestCase

TestCase is the most common class for writing tests in Django. It allows us to mock queries to the database.

setUpTestData() lets us create initial data once, at the class level, for the entire TestCase. This technique allows for much faster tests than creating the data from scratch for each individual unit test within the class.

Let's test out our Post database model.

# posts/tests.py
from django.test import TestCase
from django.urls import reverse
from .models import Post


class PostTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.post = Post.objects.create(text="This is a test!")

    def test_model_content(self):
        self.assertEqual(self.post.text, "This is a test!")

    def test_url_exists_at_correct_location(self):
        response = self.client.get("/posts/")
        self.assertEqual(response.status_code, 200)

    def test_homepage(self):
        response = self.client.get(reverse("posts"))
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, "posts/posts.html")
        self.assertContains(response, "This is a test!")

The first unit test, test_model_content, checks that the data in our mock database matches what was initially created in setUpTestData. Then we check the URL to confirm it returns an HTTP 200 Response. And finally test_homepage uses reverse to call the URL name, checks for an HTTP 200 Response, verifies the correct template is used, and confirms that HTML content matches what is expected.

We can run tests on a project-wide basis or specify a more granular approach, such as per-app by adding the app name to the end. Let's test just these three new tests in the posts app.

(.venv) $ python manage.py test posts
Found 3 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...
----------------------------------------------------------------------
Ran 3 tests in 0.008s

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

Now run all our project's tests which will include both the pages app and the posts app.

(.venv) $ python manage.py test
Found 11 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...........
----------------------------------------------------------------------
Ran 11 tests in 0.012s

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

Test Layout

As Django projects grow in complexity, it is common to delete the initial app/tests.py file and replace it with an app-level tests folder that contains individual tests files for each area of functionality. Therefore instead of a single pages/tests.py file you could have a pages/tests/ directory and within it decided files--all starting with test_--for different areas of the app.

|__pages
    |__tests
        |-- __init__.py
        |-- test_forms.py   
        |-- test_models.py   
        |-- test_views.py   

We don't have anywhere near enough tests yet for this refactoring to make sense but as a project expands further dividing the tests in this fashion can help reason about the code.

Next Steps

There is far more testing-wise that can be added to a Django project. A short list includes:

All three of my books on Django contain comprehensive testing so if you're interested in learning more about Django testing, they are a good resource (and the first several chapters of each can be read for free online):

I also recommend Adam Johnson's book, Speed Up Your Django Tests, which is a more advanced but excellent guide to tests.

Join My Newsletter

Subscribe to get the latest tutorials/writings by email.

    No spam. Unsubscribe at any time.