Photo by Ales Nesetril on Unsplash

Test Driven Development with Django

What is Test Driven Development (TDD)?

Test Driven Development is a software development technique where tests are written first and then the source code is written thereafter. Its workflow can be summarised by the following bullet points;

  • Tests should be written first — The tests cannot pass because the source code they are meant to test does not exist yet at this point.
  • Source code is written to deliver the requirements that are expected in the tests.
  • The tests are run.
  • If they pass, this means that the source code meets the requirements that were defined in the tests.
  • If the tests do not pass, the source code is refactored to meet the requirements.
  • Repeat the steps above for every test.

TDD sounds counter-intuitive. So why on earth would you want use it? Turns out there are some pretty good reasons to adopt it. Consider some of the points below.

  1. TDD can be thought of as stating the requirements in terms of test cases. Source code is written thereafter with the focus of delivering on those requirements. When the tests eventually pass, you can be confident that the requirement has been met. This kind of focus can help developers avoid scope creep.
  2. TDD can improve developer productivity via short development cycles. Working on individual requirements at a time minimises the factors at play. You work with manageable increments to code. Breaking changes will be easier to track and solve. Debugging effort is reduced. This increases efficiency and more time is spent on development.
  3. The tests are written with requirements in mind. Because of this, they are more likely to be written with a clearly identifiable purpose. Such tests can act as sufficient documentation to a code base.
  4. The practice of writing tests first ensures that your source code always has tests. On top of that, it also guarantees that test coverage is always maintained to a reasonable percentage as the code base grows.

However, TDD is not a silver bullet! There are situations where it is not ideal e.g.

  1. When the required specifications are not concise — Sometimes the application’s requirements are better understood as development progresses. Any tests that were initially written in such a case may become obsolete. This leads to more effort being devoted to clean up the test suite instead of doing development work.
  2. When developing a proof of concept — e.g. during a hackathon. Tests are usually not a priority considering the usual time constraints associated with such events.

This excerpt from the Django website summarises everything I’ve been trying to communicate thus far.

Django — A High Level Overview

Django labels itself as a “web framework for perfectionists with deadlines”. It’s a Python framework, and just like most other python libraries, it’s cool and super fun to work with.

Django comes prepackaged with views and templates (picture html pages), models (picture database tables) and many other wonderful out of the box goodies listed on their topic guide.

Django documentation is one of the best and most helpful of any library/framework.

Installations

Since we want to use Django, we have to first install Python. We’re going to use Python 3.7 for this tutorial.

Installing Python 3.7

Follow the instructions on how to install Python in the official Python page for your platform.

To confirm successful installation, open the terminal and type in the following.

python3.7 --version

If the installation was successful, you should see output similar to what is shown below.

Python 3.7.0

The small matter of virtual environments

Before installing Django (or most other Python libraries for that matter), the rule of thumb is that you should create a virtual environment for each project you have.

A virtual environment is, simply put, a folder on your computer where you can install dependencies that are specific to your project. It helps us isolate environments belonging to multiple projects on a system. Virtual environments help us to avoid dependency hell.

Using virtualenv will be sufficient for this post. You can install it using the command below.

pip install virtualenv

We only need to learn three commands;

  • The command to create a virtual environment called env.
virtualenv env
  • The command to activate a virtual environment called env.
source env/bin/activate
  • The command to deactivate a virtual environment
deactivate

We can now install Django inside the env virtual environment.

Installing Django

We will be using Django 2.1.7 (latest at the time of writing).

Before we install Django, we need to create a repository and a virtual environment where our application’s code will reside.

Let’s name this repository converter.

mkdir converter

Then we open our terminal and navigate to the converter folder.

cd converter/

Create a virtual environment called env.

virtualenv -p python3.7 env
The -p flag is used to specify the version of Python to be used within the virtual environment.

Activate the virtual environment.

source env/bin/activate

Activating the virtual environment should ALWAYS include its name in brackets in the prompt of your terminal. This is how my prompt looks like.

(env) ➜  converter

We are now ready to install Django 2.1.7.

pip install Django==2.1.7

Now that Django is installed, we can give a brief overview of the app we want to create.

The App — A Measurement Unit Converter

By the end of this tutorial, we hope to have demonstrated how to use TDD to create a Django application.

The application in question is a measurement unit converter. It was chosen because measurement conversion is a simple concept to understand yet it allows us to leave a lot of room for further exploration of TDD and testing in general.

We will focus on conversions between the following units of measurements.

  • Metre
  • Centimetre
  • Mile

Let’s begin!

Creating the “converter” Project

To create a Django app, we first have to create a Django project.

Django projects contain Django apps.

We create a project with the name converter.

django-admin startproject converter .
The period character at the end of the command above creates the converter project in the current directory.

Up to this far, your converter directory should have these contents.

converter/
converter/
init.py
settings.py
urls.py
wsgi.py
manage.py
env/
# contains virtual env related directories and files

Creating The “length” App

Django apps reside within Django projects.

Let’s create the length app inside the converter project.

django-admin startapp length

The directory structure of converter will now include the length app.

converter/
converter/
init.py
settings.py
urls.py
wsgi.py
length/
init.py
admin.py
apps.py
migrations/
init.py
models.py
tests.py
views.py
manage.py
env/
# contains virtual env related directories and files

Configuring the App

We need to modify some configurations to get the app ready for development.

We start by including the length app in the INSTALLED_APPS setting. This setting can be found in converter/settings.py.

INSTALLED_APPS = [
    .
.
.
    'length',
]

Next, we need to create a blank url file for our length app. This will store any url that is used to process length conversion requests.

touch length/urls.py

Once this is done, add a reference tolength/urls.py in converter/urls.py.

from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('length/', include('length.urls')),
]

The reference to length.urls makes the converter project cognisant of any urls associated with length conversion.

We are now ready to use TDD to develop our converter app!

Photo by Hitesh Choudhary on Unsplash

Writing Tests

Our specifications for the converter app are reasonably well defined. The app only needs to translate between different units of measurements.

Well defined specifications allow us to make use of TDD.

The tests will be defined in length/tests.py.

We begin by creating the TestLengthConversion class. It will contain unit tests that are related to length measurement conversions.

from django.test import TestCase, Client
from django.urls import reverse
class TestLengthConversion(TestCase):
"""
This class contains tests that convert measurements from one
unit of measurement to another.
"""
    def setUp(self):
"""
This method runs before the execution of each test case.
"""
self.client = Client()
self.url = reverse("length:convert")

At the top of the length/tests.py, we will import the Client class. This is a testing browser that enables us to make http requests within Django tests.

The reverse function is imported in order to return a url when the url’s name is passed in as an argument.

We can now proceed to write tests from our requirements.

Requirement 1: Convert Length Measurement From Centimetres to Metres

Let’s create a unit test for this requirement inside TestLengthConversion.

class TestLengthConversion(TestCase):
.
.
.
def test_centimetre_to_metre_conversion(self):
"""
Tests conversion of centimetre measurements to metre.
"""
        data = {
"input_unit": "centimetre",
"output_unit": "metre",
"input_value": round(8096.894, 3)
}
        response = self.client.get(self.url, data)
        self.assertContains(response, 80.969)

The test is expecting our application to convert 8096.894 centimetres to 80.969 metres (3 decimal places).

Running the tests at this point will result to a failure since the source code does not exist yet.

> python manage.py test length
.
.
.
raise ImproperlyConfigured(msg.format(name=self.urlconf_name))
django.core.exceptions.ImproperlyConfigured: The included URLconf '<module 'length.urls' from '/Users/user/projects/converter/length/urls.py'>' does not appear to have any patterns in it. If you see valid patterns in the file then the issue is probably caused by a circular import.

The only way we can fix this test is by implementing centimetre to metre conversion.

Implementing Requirement 1

The straightforward way to deal with unit conversions is as follows;

  1. Get the input value and its unit of measurement
  2. Convert the input value to it’s equivalent measurement in S.I. Unit (Input values will be converted to metres for length measurements, kilograms for mass measurements etc)
  3. Convert the S.I. Unit from 2 above to the desired output value

Let’s start by defining a form that will contain all the necessary fields. Create a file called length/form.py and add the following content.

from django import forms
class LengthConverterForm(forms.Form):
MEASUREMENTS = (
('centimetre', 'Centimetre'),
('metre', 'Metre'),
)
input_unit = forms.ChoiceField(choices=MEASUREMENTS)
input_value = forms.DecimalField(decimal_places=3)
output_unit = forms.ChoiceField(choices=MEASUREMENTS)
output_value = forms.DecimalField(decimal_places=3, required=False)

We then define a template to display the form above in html. Create a file called length/templates/length.html and add the following content.

<html lang="en">
<head>
<title>Length Conversion</title>
</head>
<body>
<form action={% url "length:convert" %} method="get">
<div>
{{ form.input_unit }}
{{ form.input_value }}
</div>
<div>
{{ form.output_unit }}
{{ form.output_value }}
</div>
<input type="submit" value="Convert"/>
</form>
</body>
</html>

We then shift the focus to the length/view.py file. Here we add the code to render the template above.

We also define how the available units of measurements will be converted to/from metres. This will aid in our conversion calculations.

from django.shortcuts import render
from length.forms import LengthConverterForm
convert_to_metre = {
"centimetre": 0.01,
"metre": 1.0
}
convert_from_metre = {
"centimetre": 100,
"metre": 1.0
}
# Create your views here.
def convert(request):
form = LengthConverterForm()
if request.GET:
input_unit = request.GET['input_unit']
input_value = request.GET['input_value']
output_unit = request.GET['output_unit']
        metres = convert_to_metre[input_unit] * float(input_value)
output_value = metres * convert_from_metre[output_unit]
        data = {
"input_unit": input_unit,
"input_value": input_value,
"output_unit": output_unit,
"output_value": round(output_value, 3)
}
        form = LengthConverterForm(initial=data)
return render(
request, "length.html", context={"form": form})
return render(
request, "length.html", context={"form": form})

Finally, we need to map the convert view to a URL. Create a file called length/urls.py and add the following;

from django.urls import path
from length import views
app_name = 'length'
urlpatterns = [
path('convert/', views.convert, name='convert'),
]

To recap what we’ve done so far;

  • A length/forms.py file was created to define the html form fields
  • A length/templates/length.html file was created to display the html form fields
  • A convert method view was created in length/views.py to render the length.html template and handle conversion logic.
  • A length/urls.py file was created to map the connect view to the /convert/ url.

And now, let’s run the test.

> python manage.py test length
.
.
.
System check identified no issues (0 silenced).
.
--------------------------------------------------------------------
Ran 1 test in 0.018s
OK

Hooray! We have successfully implemented centimetre to metre conversion in our app.

Successful implementation can be verified by viewing the app on your browser window. Run the following command to start the development server…

python manage.py runserver

and then visit http://127.0.0.1:8000/length/convert/ to access the length conversion app.

Let’s move on to the next requirement and create a test for it.

Requirement 2: Convert Length Measurement From Centimetres to Miles

As we did earlier, we define a test first. Open up length/tests.py and add the centimetre to miles conversion test.

    .
.
.
def test_centimetre_to_mile_conversion(self):
data = {
"input_unit": "centimetre",
"output_unit": "mile",
"input_value": round(985805791.3527409, 3)
}
    response = self.client.get(self.url, data)
self.assertContains(response, 6125.511)

This test is expecting our application to convert 985805791.353 centimetres to 6125.511 miles (3 decimal places).

TL;DR — Random Value Generation

Did 985805791.3527409 look fairly random? That’s because it is!

import random
random.uniform(1, 1000000000)

Instead of hardcoding the input_values like we have done, you could opt to create a utility function that returns a random number which lies between a range e.g. 1 and 1 billion as shown above.

The peace of mind you get when your tests can pass with randomly generated values is unmatched. It means your app is versatile enough to handle whatever value is thrown at it.

I digress.

Implementing Requirement 2

We have already done most of the ground work with Requirement 1 above. Now we need to determine how to convert miles to metres and vice versa.

Open length/views.py and add the mile key to the convert_to_metre and convert_from_metre maps. The final state of the maps should appear as shown below.

convert_to_metre = {
"centimetre": 0.01,
"metre": 1.0,
"mile": 1609.34
}
convert_from_metre = {
"centimetre": 100,
"metre": 1.0,
"mile": 0.000621371
}

Then add the “Mile” option to the MEASUREMENTS contstant in length/forms.py.

MEASUREMENTS = (
('centimetre', 'Centimetre'),
('metre', 'Metre'),
('mile', 'Mile')
)

These are all the changes we need to make to get the tests to pass.

> python manage.py test length
.
.
.
System check identified no issues (0 silenced).
..
--------------------------------------------------------------------
Ran 2 tests in 0.024s
OK

Voila!

Conclusion

We have used TDD to create a simple Django application that converts length measurements.

We walked through the TDD cycle of test definition, test failure and implementation/refactoring to make the test pass. As we did this, we saw that;

  • A passing test basically guaranteed that a requirement was met.
  • We have attained and maintained a decent test coverage.
  • We wrote tests that could act as documentation for anyone who is unfamiliar with our work

Like we mentioned earlier, the measurement conversion app leaves a lot of room for individual exploration. Some ideas for exploration are;

  • adding more units of length measurement e.g. Inch, Kilometre, Yard, Millimetre, Foot e.t.c.
  • adding more tests to verify that reverse conversions can also work e.g. from Requirement 1, add a test to check that metre to centimetre conversion also works.
  • extending the application's functionality to include conversions for Mass, Temperature, Digital Storage (MB vs GB vs TB) e.t.c.

If you do decide to explore the last option, the Creating The “length” App and Configuring the App sections of this post will come in handy.

The code for this application can be found here.

Happy Coding!