
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 slides. This 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 *
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 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:
- Those who stand on "code should be covered 100%"
- 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:
[pytest]
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
[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 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:
[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%
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'
@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!
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.
You could also click on any of the social platform icons below and see what we are up to, day to day.