Test Driven Development with Django

My name is Brenton Cleeland

I work at Thoughworks in Melbourne 🇦🇺

I've travelled a long way! 😴

Today

Format

These slides are available online

https://sesh.github.io/django-tdd/

So, what is TDD?

Test-Driven Development (TDD) is a technique for building software that guides software development by writing tests

- Martin Fowler

  1. Write a test
  2. Write code to make the test pass
  3. Refactor to ensure clean code

🔴💚🔁

Refactor both your code and tests

Rules, you say?

The Three Laws of TDD

- Robert Martin

  1. You are not allowed to write any production code unless it is to make a failing unit test pass.
  1. You are not allowed to write any more of a unit test than is sufficient to fail; and compilation failures are failures.
  1. You are not allowed to write any more production code than is sufficient to pass the one failing unit test.

Generalisation

Avoid writing code for the "general" case when you don't have to

def test_is_prime(self):
    self.assertTrue(is_prime(3))
def is_prime(num):
    return num == 3
def test_is_prime(self):
    self.assertTrue(is_prime(3))
    self.assertTrue(is_prime(7))
    self.assertTrue(is_prime(137))

Think about and test edge cases

def test_is_prime_negative(self):
    self.assertFalse(is_prime(-1))

In Python tests live in files that start with "test"

In Django, we normally create test classes based on django.test.TestCase

There are other unittest.TestCase subclasses that let you do different things

They all provide the Django Test Client which we will use to make API calls

Let's write an API

Current Time API

As a user I want to receive the current UTC time so I can ensure my clock is correct

Writing a test before you write any code

# time_api/tests.py
from django.test import TestCase

class TimeApiTestCase(TestCase):

    def test_time_url_is_status_okay(self):
        response = self.client.get('/api/time/')
        self.assertEqual(200, response.status_code)

🔴

Lets update our code to handle the call to our API

# time_api/urls.py
from django.contrib import admin
from django.urls import path

urlpatterns = [
    path('admin/', admin.site.urls),
]
# time_api/urls.py
from django.contrib import admin
from django.urls import path

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/time/', time_api),  # 1
]
# time_api/urls.py
from django.contrib import admin
from django.urls import path

from django.http import HttpResponse  # 3

def time_api(request):  # 2
    return HttpResponse()

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/time/', time_api),  # 1
]

💚

🔁 ?

Write a test to ensure we return JSON

# time_api/tests.py
from django.test import TestCase

class TimeApiTestCase(TestCase):

    def test_time_api_should_return_json(self):
        response = self.client.get('/api/time/')
        self.assertEqual('application/json', response['Content-Type'])

🔴

# time_api/urls.py
from django.contrib import admin
from django.urls import path

from django.http import HttpResponse, JsonResponse  # 2

def time_api(request):
    return JsonResponse({})  # 1

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/time/', time_api),
]

💚

🔁 ?

# time_api/urls.py
from django.contrib import admin
from django.http import JsonResponse  # 1
from django.urls import path

def time_api(request):
    return JsonResponse({})

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/time/', time_api),
]

💚

Next test: we should have the current_time key

# time_api/tests.py
from django.test import TestCase

class TimeApiTestCase(TestCase):

    def test_time_api_should_include_current_time_key(self):
        response = self.client.get('/api/time/')
        self.assertTrue('current_time' in response.json())

🔴

# time_api/urls.py
from django.contrib import admin
from django.http import JsonResponse
from django.urls import path

def time_api(request):
    return JsonResponse({
        'current_time': ''  # 1
    })

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/time/', time_api),
]

💚

🔁 ?

Time to test the formatting of the date

ISO 8601

'%Y-%m-%dT%H:%M:%SZ'

# time_api/tests.py
from django.test import TestCase
from datetime import datetime

class TimeApiTestCase(TestCase):

    def test_time_api_should_return_valid_iso8601_format(self):
        response = self.client.get('/api/time/')
        current_time = response.json()['current_time']
        dt = datetime.strptime(current_time, '%Y-%m-%dT%H:%M:%SZ')
        self.assertTrue(isinstance(dt, datetime))

🔴

# time_api/urls.py
from django.contrib import admin
from django.http import JsonResponse
from django.urls import path
from django.utils import timezone

def time_api(request):
    return JsonResponse({
        'current_time': '2018-01-01T08:00:00Z'  # 1
    })

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/time/', time_api),
]

💚

🔁 ?

We should generalise!

A quick interjection about Mocking 🧙‍♂️

Mocking is okay

Mocking is pretty easy in Python

with patch('django.utils.timezone.now') as mock_tz_now:
    expected_datetime = datetime(2018, 1, 1, 10, 10, tzinfo=timezone.utc)
    mock_tz_now.return_value = expected_datetime

Generalising by testing that we're returning the "current" time

# time_api/tests.py
from datetime import datetime
from unittest.mock import patch

from django.test import TestCase

class TimeApiTestCase(TestCase):
    def test_time_api_should_return_current_utc_time(self):
        with patch('django.utils.timezone.now') as mock_tz_now:
            expected_datetime = datetime(2018, 1, 1, 10, 10, tzinfo=timezone.utc)
            mock_tz_now.return_value = expected_datetime

            response = self.client.get('/api/time/')
            current_time = response.json()['current_time']
            parsed_time = datetime.strptime(current_time, '%Y-%m-%dT%H:%M:%SZ')

            self.assertEqual(parsed_time, expected_datetime)
# time_api/urls.py
from django.contrib import admin
from django.http import JsonResponse
from django.urls import path
from django.utils import timezone

def time_api(request):
    return JsonResponse({
        'current_time': timezone.now().strftime('%Y-%m-%dT%H:%M:%SZ')  # 1
    })

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/time/', time_api),
]

💚

🔁 ?

Move the time format to our settings

# time_api/settings.py
DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%SZ'  # 1
# time_api/urls.py
from django.conf import settings  # 3
from django.contrib import admin
from django.http import JsonResponse
from django.urls import path
from django.utils import timezone

def time_api(request):
    return JsonResponse({
        'current_time': timezone.now().strftime(settings.DATETIME_FORMAT) # 2
    })

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/time/', time_api),
]

💚

🔁 ?

Move our view into its own app

> ./manage.py startapp times
# times/views.py
from django.conf import settings
from django.http import JsonResponse
from django.utils import timezone


def time_api(request):
    return JsonResponse({
        'current_time': timezone.now().strftime(settings.DATETIME_FORMAT)
    })
# time_api/urls.py
from django.contrib import admin
from django.urls import path

from times.views import time_api

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/time/', time_api),
]

💚

🔁 ?

Okay, lets finish by moving the tests into the times app as well

# times/tests.py
from datetime import datetime
from unittest.mock import patch

from django.test import TestCase

class TimeApiTestCase(TestCase):

    def test_time_url_is_status_okay(self):
        response = self.client.get('/api/time/')
        self.assertEqual(200, response.status_code)

    def test_time_api_should_return_json(self):
        response = self.client.get('/api/time/')
        self.assertEqual('application/json', response['Content-Type'])


    def test_time_api_should_include_current_time_key(self):
        response = self.client.get('/api/time/')
        self.assertTrue('current_time' in response.json())

    def test_time_api_should_return_valid_iso8601_format(self):
        response = self.client.get('/api/time/')
        current_time = response.json()['current_time']
        dt = datetime.strptime(current_time, '%Y-%m-%dT%H:%M:%SZ')
        self.assertTrue(isinstance(dt, datetime))

    def test_time_api_should_return_current_utc_time(self):
        with patch('django.utils.timezone.now') as mock_tz_now:
            expected_datetime = datetime(2018, 1, 1, 10, 10, tzinfo=timezone.utc)
            mock_tz_now.return_value = expected_datetime

            response = self.client.get('/api/time/')
            current_time = response.json()['current_time']
            parsed_time = datetime.strptime(current_time, '%Y-%m-%dT%H:%M:%SZ')

            self.assertEqual(parsed_time, expected_datetime)

💚

We've just written 5 tests for our API

Django makes this type of test easy & fast

Okay, now it's your turn 🏃🏻

Think about how you would break down the task into testable units

Don't write any code until you have a failing test

Pair up. Ping-pong + TDD works great.

🔴💚🔁

As a user I want to receive the current UTC time so I can ensure my clock is correct

How do we know we're covering all of our code?

Delete a line, make sure a test fails

Check coverage with coverage.py

> pip install coverage
> coverage run --source . manage.py test
> coverage report

You can grab the code up until this point at:

https://github.com/sesh/time-tdd

Ready for another challenge? 🏄

As a user I want to receive the current time in the timezone that I provide so I can see what time it is

How did you approach the challenge?

Here's some of the tests I wrote:

Okay, last challenge 👊

But first, TDD + Django Models

def test_can_create_person(self):
    person = Person.objects.create(
        name="Brenton Cleeland",
        country="AU",
        twitter="@sesh",
    )
def test_can_create_person(self):
    try:
        person = Person.objects.create(
            name="Brenton Cleeland",
            country="AU",
            twitter="@sesh",
        )
    except:
        self.fail('Creating model caused an exception')

🔴

You will need to create the model and the migration

To the feature 👷

As a user I want to provide the name of a city and country and get the current time in that location

Here's some tests that I wrote:

Awesome-sauce 🍅

Let's recap

Only write the code required to make your test pass

Think about edge cases

TDD is a discipline and takes practice

Try using TDD on your next side project

Danke 🙏