Test Driven Development with Pytest picture
Image by Afrowave

Test Driven Development with Pytest

16 minute read

I made a decision that I will build my own website and all the next upcoming projects using the Test Driven Development method. I mean, I can't call myself "world class" and I don't follow the best practices used by professional devs in the wild, can I?

So I searched online for the best way to do this for a Django project and I found the book by Harry Percival, Obey the Testing Goat!  I 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.

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

I worked with pyenv because I wanted to make sure my 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 *

    "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 suffer from OCD, 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".

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

mock is a third party mocking application 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:

DJANGO_SETTINGS_MODULE = tested.test_settings
addopts = --nomigrations --cov=. --cov-report=html

The addopts are the options that 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

$ py.test

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

$ 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

omit =

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

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

We run the test:

$ py.test

And we get:   


E     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, I got:

collected 2 items

birdie/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 I do not get the coverage HTML report. I opened my .coveragerc and removed all the omitted files. I tested again and a coverage report was produced but with the test files included. On going through them one at a time, I found that one must specify the path where the "tests" files are so that they are omitted. So my .coverage looks likes this now:

omit =


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%

I 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, my 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.py 

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:

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

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:

# views.py

from django.views.generic import TemplateView    

class 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:

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

So we go ahead and add our AccessView:

class AccessView(TemplateView):
    template_name = 'birdie/access.html'

    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'


E   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

You could also click on any of the social platform icons below and see what we are up to, day to day.

Last updated: March 28, 2022, 9:45 p.m.