Django Testing Tutorial
Tags: django, python, tutorial, testing
Published: 2020-03-06 11:20:44

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.

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

* *Unit Tests* are small, isolated, and focus on one specific function.
* *Integration Tests* are aimed at mimicking user behavior and combine multiple pieces of code and functionality.

While we might we use a *unit test* to confirm that the homepage returns an HTTP status code of 200, an *integration test* might 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 a 200 response on the homepage, but we can--and should--also test that the homepage does not return something unexpected, like a 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.

Complete source code is [available on Github](https://github.com/wsvincent/django-testing-tutorial).

## When to run tests
The short answer is **all the time!** Practically speaking, whenever code is pushed or pulled from a repo to a staging environment is ideal. A continuous integration service can perform this automatically. You should also re-run all tests when upgrading software packages, especially Django itself.

## Layout
By default all new apps in a Django project come with a `tests.py` file. Any test within this file that starts with `test_` will be run by Django's test runner. **Make sure all test files start with test_**.

As projects grow in complexity, it's recommended to delete this initial `tests.py` file and replace it with an app-level `tests` folder that contains individual tests files for each area of functionality.

For example:

```
|__app
    |__tests
        |-- __init__.py
        |-- test_forms.py   
        |-- test_models.py   
        |-- test_views.py   
```


## Sample Project
Let's create a small Django project from scratch and thoroughly test it. It will mimic the [message board app](https://djangoforbeginners.com/message-board/) from Chapter 4 of [Django for Beginners](https://djangoforbeginners.com/).

On the command line run the following commands to start our new project. We'll place the code in a folder called `testy` on the Desktop, but you can locate the code anywhere you choose.

```
$ cd ~/Desktop
$ mkdir testy && cd testy
$ pipenv install django
$ pipenv shell
(testy) $ django-admin startproject myproject .
(testy) $ python manage.py startapp pages
```

Now update `settings.py` to add our new `pages` app and configure Django to look for a project-level `templates` folder.

```python
# myproject/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
]

TEMPLATES = [
    ...
        'DIRS': [os.path.join(BASE_DIR, 'templates')], # new
    ...
]
```

Create our two templates to test for a homepage and about page.

```
(testy) $ mkdir templates
(testy) $ touch templates/home.html
(testy) $ touch templates/about.html
```

Populate the templates with the following simple code.

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

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

Update the project-level `urls.py` file to point to the `pages` app.

```python
# myproject/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
]
```

Create a `urls.py` file within the `pages` app.

```
(testy) $ touch pages/urls.py
```

Then update it as follows:

```python
# 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'),
]
```

And as a final step add our views.

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


class HomePageView(TemplateView):
    template_name = 'home.html'


class AboutPageView(TemplateView):
    template_name = 'about.html'
```

Start up the local Django server.

```
(testy) $ python manage.py runserver
```

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

![Homepage](https://dev-to-uploads.s3.amazonaws.com/i/saech0x5jz4r4610qm0b.png)

![About page](https://dev-to-uploads.s3.amazonaws.com/i/g7gw2oif7htttg465o1c.png)

Time for tests.


## SimpleTestCase
Our Django application only has two static pages at the moment. There's no database involved which means we should use [SimpleTestCase](https://docs.djangoproject.com/en/2.0/topics/testing/tools/#simpletestcase).

We can use the existing `pages/tests.py` file for our tests for now. Take a look at the code below which adds five tests for our homepage. First we test that it exists and returns a `200` HTTP status code. Then we confirm that it uses the url named `home`. We check that the template used is `home.html`, the HTML matches what we've typed so far, and even test that it does not contain incorrect HTML. It's always good to test both **expected** and **unexpected** behavior.

```python
# pages/tests.py
from django.http import HttpRequest
from django.test import SimpleTestCase
from django.urls import reverse

from . import views


class HomePageTests(SimpleTestCase):

    def test_home_page_status_code(self):
        response = self.client.get('/')
        self.assertEquals(response.status_code, 200)

    def test_view_url_by_name(self):
        response = self.client.get(reverse('home'))
        self.assertEquals(response.status_code, 200)

    def test_view_uses_correct_template(self):
        response = self.client.get(reverse('home'))
        self.assertEquals(response.status_code, 200)
        self.assertTemplateUsed(response, 'home.html')

    def test_home_page_contains_correct_html(self):
        response = self.client.get('/')
        self.assertContains(response, '<h1>Homepage</h1>')

    def test_home_page_does_not_contain_incorrect_html(self):
        response = self.client.get('/')
        self.assertNotContains(
            response, 'Hi there! I should not be on the page.')
```

Now run the tests.

```
(testy) $ python manage.py test
```

They should all pass.

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...

```python
# pages/tests.py
class AboutPageTests(SimpleTestCase):

    def test_about_page_status_code(self):
        response = self.client.get('/about/')
        self.assertEquals(response.status_code, 200)

    def test_view_url_by_name(self):
        response = self.client.get(reverse('about'))
        self.assertEquals(response.status_code, 200)

    def test_view_uses_correct_template(self):
        response = self.client.get(reverse('about'))
        self.assertEquals(response.status_code, 200)
        self.assertTemplateUsed(response, 'about.html')

    def test_about_page_contains_correct_html(self):
        response = self.client.get('/about/')
        self.assertContains(response, '<h1>About page</h1>')

    def test_about_page_does_not_contain_incorrect_html(self):
        response = self.client.get('/')
        self.assertNotContains(
            response, 'Hi there! I should not be on the page.')
```


## Message Board app
Now let's create our *message board* app so we can try testing out database queries. First create another app called `posts`.

```
(testy) $ python manage.py startapp posts
```

Add it to our `settings.py` file.

```python
# myproject/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 create our initial database.

```
(testy) $ python manage.py migrate
```

Now add a basic model.

```python
# 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.

```
(testy) $ python  manage.py makemigrations posts
(testy) $ python manage.py migrate posts
```

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

```
(testy) $ python manage.py createsuperuser
```

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

```python
# 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/](http://127.0.0.1:8000/admin/). You should see the admin’s login screen:

![Admin page](https://dev-to-uploads.s3.amazonaws.com/i/lbfqr6yvregg5qkh9ix5.png)

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

![Admin add post page](https://dev-to-uploads.s3.amazonaws.com/i/1tm2mihzuc6o4kwj94h6.png)

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

![Admin hello world page](https://dev-to-uploads.s3.amazonaws.com/i/fz02iie03nq58xm38ikt.png)

Now add our `views` file.

```python
# posts/views.py
from django.views.generic import ListView
from .models import Post


class PostPageView(ListView):
    model = Post
    template_name = 'posts.html'
```

Create a `posts.html` template file.

```
(testy) $ touch templates/posts.html
```

And add the code below to simply output all posts in the database.

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

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

```python
# myproject/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 `urls.py` file in the `posts` app.

```
(testy) $ touch posts/urls.py
```

And populate it as follows.

```python
# 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](http://127.0.0.1:8000/posts).

![Posts page](https://dev-to-uploads.s3.amazonaws.com/i/qglawh7ychoh7zesk98u.png)

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


## TestCase
[TestCase](https://docs.djangoproject.com/en/2.0/topics/testing/tools/#testcase) is the most common class for writing tests in Django. It allows us to mock queries to the database.

Let's test out our `Post` database model.

```python
# posts/tests.py
from django.test import TestCase
from django.urls import reverse

from .models import Post


class PostTests(TestCase):

    def setUp(self):
        Post.objects.create(text='just a test')

    def test_text_content(self):
        post = Post.objects.get(id=1)
        expected_object_name = f'{post.text}'
        self.assertEquals(expected_object_name, 'just a test')

    def test_post_list_view(self):
        response = self.client.get(reverse('posts'))
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, 'just a test')
        self.assertTemplateUsed(response, 'posts.html')
```

With `TestCase` the Django test runner will create a sample test database just for our tests. Here we've populated it with the text `'just a test'`.

In the first test we confirm that the test entry has the primary id of `1` and the content matches. Then in the second test on the view we confirm that that it uses the url name `posts`, has a 200 HTTP response status code, contains the correct text, and uses the correct template.

Run the new test to confirm everything works.

```
(testy) $ python manage.py test
```


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

* **Continuous Integration**: automatically run all tests whenever a new commit is made, which can be done using Github Actions or a service like [Travis CI](https://travis-ci.org/).
* **pytest**: [pytest](https://docs.pytest.org/en/latest/) is the most popular enhacement to Django and Python's built-in testing tools, allowing for more repeatable tests and a heavy use of fixtures.
* **coverage**: With [coverage.py](https://docs.djangoproject.com/en/2.0/topics/testing/advanced/#integration-with-coverage-py) you can have a rough overview of a project's total test coverage.
* **[Integration tests](https://docs.djangoproject.com/en/2.0/topics/testing/tools/#liveservertestcase)**: useful for testing the flow of a website, such as authentication or perhaps even payments that rely on a 3rd party.

I'm working on a future course on advanced Django testing so make sure to sign up for the LearnDjango newsletter below to be notified when it's ready.

If you need more testing help now, an excellent course is [Test-Driven Development with Django, Django REST Framework, and Docker](https://testdriven.io/courses/tdd-django/?utm_source=will) by my friend Michael Herman.