Django Test Driven Development with Pytest

Oct 13, 2017 · 7 min read
Image for post
Image for post

We made the decision that our own website and all the next upcoming projects will be built using the Test Driven Development method. We need to be “world class” and follow the best practices used by professional devs in the wild.

On searching online for the best way to do this for a Django project we found the book by Harry Percival, Obey the Testing Goat! . We started with Harry Percival’s workshop — TDD with Django, from scratch a beginner’s intro to testing and web development that took place at PyCon 2015.

We followed it up with a talk by Martin Brochhaus at the Singapore Djangonauts, titled The Django Test Driven Development Cookbook and the slides. This tutorial is based on this talk and what we found interesting as we worked through it.

We use pyenv because it allows us to make sure our application can run on different versions of Python.

1. Set up a file

You should put in the in-memory SQLite settings for testing database stuff. Normally, when testing databases, separate “blank” databases are created and destroyed after every test.

If you use a regular “production” database, MySQL or PostgreSQL, writing to the a hard disk will significantly slow down your tests. So we are specifically swopping out the parts in the file that require a certain amount of input-output(IO).

The EMAIL_BACKEND into test sending out emails. You do not want to accidentally send out emails while testing.

"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": "memory:",
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"

2. In your python environment, install pytest

If you are have a mild Obsessive-Compulsive Disorder as a developer, install the plugins you need.

pytest-ipdb is for setting breakpoints in the test and you will be able to use the ipdb debugger. This former has colour and code-completion when compared with the latter.

pytest-cov is for generating a coverage report that is based on how much of your code is covered by the tests. There are two camps in coverage:

  1. Those who stand on “code should be covered 100%”
  2. Those who say “covering 100% does not mean that you have good tests”.

We are in the former camp of 100% coverage. Reason? Tests over time will improve while maintaining 100% coverage. But coverage over time will not improve even with the best tests if you start off below 100% coverage. At some point the test suite will not be useful.

mock is a Python mocking library that allows one to create an API of payment gateways and other services.

3. Setup your test configuration

Create a pytest.ini that will sit in the root directory of the project. Type in the following:

The addopts are options one may add on the command line interface (CLI). We want no migrations for the database and we want a coverage report in HTML. Other options are available here -

====================== test session starts ======================platform darwin -- Python 3.6.0, pytest-3.0.5, py-1.4.32, pluggy-0.4.0
Django settings: tested.test_settings (from ini file)
rootdir: /Users/jimmygitonga/devzone/tests/tested, inifile: pytest.ini
plugins: cov-2.5.1, django-3.1.2, ipdb-0.1.dev2
collected 0 items
-------- coverage: platform darwin, python 3.6.0-final-0 ---------
Coverage HTML written to dir htmlcov
================== no tests ran in 0.09 seconds ==================

When you do open the html coverage document, it will include some files that have no need to be tested. We don’t need to test the test files. v4.4.1

4. We want 100% coverage

We create a Coverage configuration file .coveragerc

We are omitting the files that do not need to be covered since they are part of the project configurations.

5. Testing models

We want to build a Twitter clone so we created our app and prepared for the tests.

To begin the tests, we install mixer which populates the database with random data that we will specify in the file. The random data is very comprehensive, adding extremely large numbers, negative numbers, unicode, and so on. This is very good for edge cases that break the code.

In order to save the data into the database using, we add pytestmark = pytest.mark.django_db.

import pytest 
from mixer.backend.django import mixer
pytestmark = pytest.mark.django_db # This is put here so that we can save to the database otherwise it will fail because tests are not written to the database.
class TestPost:
def test_init(self):
obj = mixer.blend('birdie.Post')
assert == 1, 'Should create a Post instance'

We run the test:

And we get:

There is no Post model in the birdie app. So we write out the Post class:

Upon testing again, we got:

birdie/tests/ warning: No data was collected. (no-data-collected)
======================= 2 passed in 0.39 seconds ===================

That is strange. The tests are passing but we did not get the coverage HTML report. We opened up the .coveragerc and removed all the omitted files. We tested again and a coverage report was produced but with the test files included. On going through them one at a time, we found that one must specify the path where the “tests” files are so that they are omitted. So our .coverage looks likes this now:


We noted the in the app is behaving differently from the the since both files only have an import statement. The can not be tested since it needs to render out some template to "pass". Therefore the coverage was 0% dropping my overall percentage. So overall, our coverage is 100% of what can be tested.

6. Testing the admin panel

We want to display the excerpts of the entries on the list display of the Admin panel. So we set up a

But the excerpts are generated as a function and are not a field in the birdie The admin panel does not exist as a model somewhere. It is instantiated every time one accesses the admin panel, with the latest model details. So to test that the admin would work as required, one must create an instance to use.

import pytest 
from django.contrib.admin.sites import AdminSite
from mixer.backend.django import mixer
from .. import admin
from .. import models
pytestmark = pytest.mark.django_db class TestPostAdmin:
def test_excerpt(self):
site = AdminSite()
post_admin = admin.PostAdmin(models.Post, site)
obj = mixer.blend('birdie.Post', body='Hello World')
result = post_admin.excerpt(obj)
expected = obj.get_excerpt(5)
assert result == expected, ('Should return the result from the .excerpt() function')

On testing, we expect to get the error:

We create the PostAdmin class in the file and we have success.

7. Testing views

In the, we create the test:

from django.test import RequestFactory 
from .. import views
class TestHomeView:
def test_anonymous(self):
req = RequestFactory().get('/')
resp = views.HomeView.as_view()(req)
assert resp.status_code == 200, 'Should be callable by anyone'

And to clear the error that we will receive:

from django.views.generic import TemplateViewclass HomeView(TemplateView):
template_name = 'birdie/home.html'

There are 2 things the above test will not test:

  • This does NOT render the view and test the template
  • This does NOT call

8. Testing access to registered users

We will use a method decorator login_required to protect our view from Anonymous users. This means we must add a .user attribute on the request.

pytestmark = pytest.mark.django_db 
from .. import views
class TestAccessView:
def test_anonymous(self):
req = RequestFactory().get('/')
req.user = AnonymousUser()
resp = views.AccessView.as_view()(req)
assert 'login' in resp.url, 'Should redirect to login'
def test_registered_user(self):
user = mixer.blend('auth.User', is_registered_user=True)
req = RequestFactory().get('/')
req.user = user
resp = views.AccessView.as_view()(req)
assert resp.status_code == 200, 'Should be callable by registered user'

Sometimes the redirect is not to the login page but to a different page. We would then test for a redirect 302 status or whatever we are hoping for.

We run the test and get:

So we go ahead and add our AccessView:

def dispatch(self, request, *args, **kwargs):
return super(AccessView, self).dispatch(request, *args, **kwargs)

9. Test posting a message

Our user will need a form. So we need to test for form entries on our site. We create a TestPostForm class to do this.

class TestPostForm:
def test_form(self):
form = forms.PostForm(data={})
assert form.is_valid() is False, (
'Should be invalid if no data is given')
data = {'body': 'Hello'}
form = forms.PostForm(data=data)
assert form.is_valid() is False, (
'Should be invalid if body text is less than 10 characters')
assert 'body' in form.errors, 'Should return field error for `body`'
data = {'body': 'Hello World!'}
form = forms.PostForm(data=data)
assert form.is_valid() is True, 'Should be valid when data is given'


So we do as it says and we add the forms module.

class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = ['body', ]
def clean_body(self):
data = self.cleaned_data.get('body')
if len(data) < 10:
raise forms.ValidationError('Please enter at more than 10 characters')
return data

Upon testing, we get a clean bill of health and now all our documented tests are at 100%.

Now we can build our “birdie” app.

If you liked this article, go ahead, click and fill out the form below and let us begin a conversation about your project.

Project Form

Originally published at There is more good stuff where this came from.

Shok and Oh!

Afroshok is a digital production and consulting, web design…


Written by


Shok and Oh!

Afroshok is a digital production and consulting, web design and development boutique in Nairobi, Kenya. We specialise in creating cutting edge, ground breaking, brand driven immersive projects. We work through Progressive Web Applications to deliver content where it matters.


Written by


Shok and Oh!

Afroshok is a digital production and consulting, web design and development boutique in Nairobi, Kenya. We specialise in creating cutting edge, ground breaking, brand driven immersive projects. We work through Progressive Web Applications to deliver content where it matters.

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store