Django Test Driven Development with Pytest

Afroshok
Shok and Oh!
Published in
7 min readOct 13, 2017

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

from .settings import * DATABASES = { 
"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.

$ pip install pytest
$ pip install pytest-django
$ pip install git+git://github.com/mverteuil/pytest-ipdb.git
$ pip install pytest-cov
$ pip install mock

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:

[pytest]
DJANGO_SETTINGS_MODULE = tested.test_settings
addopts = --nomigrations --cov=. --cov-report=html

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 - http://pytest-cov.readthedocs.io/en/latest/readme.html#reporting

$ pytest ====================== 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.

$ open htmlcov/index.html

Coverage report: 51%
Module statements missing excluded coverage manage.py 13 13 0 0% tested/__init__.py 0 0 0 100% tested/settings.py 18 0 0 100% tested/test_settings.py 3 0 0 100% tested/urls.py 3 3 0 0% tested/wsgi.py 4 4 0 0%
Total 41 20 0 51%
coverage.py v4.4.1

4. We want 100% coverage

We create a Coverage configuration file .coveragerc

[run] 
omit =
*apps.py,
*migrations/*,
*settings*,
*tests/*,
*urls.py,
*wsgi.py,
manage.py

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.

$ pip install mixer 
$ django-admin.py startapp birdie
$ rm birdie/tests.py
$ mkdir birdie/tests
$ touch birdie/tests/__init__.py
$ touch birdie/tests/test_models.py

To begin the tests, we install mixer which populates the database with random data that we will specify in the test_models.py 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 test_models.py, we add pytestmark = pytest.mark.django_db.

# test_models.pyimport 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 obj.pk == 1, 'Should create a Post instance'

We run the test:

$ pytest

And we get:

ValueError: Invalid scheme: birdie.Post

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

from django.db import models class Post(models.Model): body = models.TextField(max_length=140)

Upon testing again, we got:

collected 2 itemsbirdie/tests/test_models.py ..Coverage.py 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:

[run]
omit =
*apps.py,
*migrations/*,
*settings*,
*urls.py,
*wsgi.py,
manage.py,
birdie/tests/*

Result:

Module          statements  missing excluded    coverage birdie/__init__.py     0       0       0       100% 
birdie/admin.py 1 0 0 100%
birdie/models.py 5 0 0 100%
birdie/views.py 1 1 0 0%
tested/__init__.py 0 0 0 100%
Total 7 1 0 86%

We noted the views.py in the app is behaving differently from the the admin.py since both files only have an import statement. The views.py 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 test_admin.py.

But the excerpts are generated as a function and are not a field in the birdie model.py. 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.

# test_admin.pyimport 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:

AttributeError: module 'birdie.admin' has no attribute 'PostAdmin'

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

7. Testing views

In the test_views.py, we create the test:

# test_views.pyfrom 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:

# views.pyfrom 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 urls.py

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.

import pytest 
from django.contrib.auth.models import AnonymousUser
from django.test import RequestFactory
from mixer.backend.django import mixer
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:

AttributeError: module 'birdie.views' has no attribute 'AccessView'.

So we go ahead and add our AccessView:

class AccessView(TemplateView):
template_name = 'birdie/access.html'
@method_decorator(login_required)
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.

import pytest 
from .. import forms
pytestmark = pytest.mark.django_db
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'

Test!

ImportError: cannot import name 'forms'

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

from django import forms 
from .models import Post
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 afroshok.com. There is more good stuff where this came from.

--

--