TDD Practice: Time API with Django

Published on

Photo by Bart Pawlik, used with permission from the DjangoCon EU organisers

This is the blog post version of a workshop I ran at DjangoConEU in 2018. Iโ€™ve updated it for, and tested with, Django 4.0 but this is otherwise mostly unchanged...

Let's learn some TDD tricks by building a small API with Django.

Treat this like a Kata. Follow the steps below exactly the first time through, then practice by completing the task a second time by yourself.

When you do it by yourself you can delete the Django project and create a new one, or use a completely different language or web framework.

So, what is TDD?

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

โ€” Martin Fowler

We're going to follow the Red -> Green -> Refactor practice of TDD in this example. This means that every line of code we write for our application will be written to make a failing test case pass. During each cycle we will:

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

When we're refactoring we can think about changes to both our production code and our test cases. You won't always need to refactor after making the test pass, but you should always take a moment to review what you've done.

For practice we will try to strictly adhere to Uncle Bob's three rules of TDD:

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

Getting setup

First, check that you have a version of Python 3 installed:

> python3 --version

If you get back something less than Python 3.6, then head over to the Python website to install the most recent version.

Create a directory for the project, you can call this anything, but I'm going to go with time-api:

> mkdir time-api
> cd time-api

Create a virtual environment to keep our project's requirements separate from any other projects:

> python3 -m venv venv

This creates a new directory called venv containing our Python virtual environment. To activate the environment you need to source the activate script:

> source venv/bin/activate

When active you should notice a (venv) prefix on your shell. You can deactivate the virtual environment at any time by running deactivate or closing your terminal window.

Now it's time to install our only dependency. Install Django inside the virtual environment with pip:

(venv)> python -m pip install django

Finally, start our Django project. The . on the end tells django-admin to create the project in the current directory:

(venv)> django-admin startproject time_api .

The user story

All great projects start with a user story. Ideally one that provides some sort of value to our customer.

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

Acceptance criteria:

  • The /api/time endpoint should return a JSON response with a current_time key
  • If successful, the status code should be 200 OK
  • All times should be in ISO 8601 format

The Kata

Let's start by thinking about the smallest piece of this story.

To me that's that the URL will be /api/time/. We can write a simple test to ensure that we receive a 200 OK response from the endpoint.

The Django test client handles a lot of the heavy lifting for us โ€“ we will use self.client.get() to make a HTTP request to our desired endpoint, and response.status_code to confirm the status code of the response.

Start out by creating a new tests.py file in the time_api directory with the test case below. Django's test runner will automatically discover an execute tests in files that start with test.

# 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)

Run the test to make sure it fails, and fails for the right reason. You should receive a message saying the 404 is not 200.

> python manage.py test
Found 1 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_time_url_is_status_okay (time_api.tests.TimeApiTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/brenton/Dev/time-api/time_api/tests.py", line 7, in test_time_url_is_status_okay
    self.assertEqual(200, response.status_code)
AssertionError: 200 != 404

----------------------------------------------------------------------
Ran 1 test in 0.003s

FAILED (failures=1)
Destroying test database for alias 'default'...

Django is successfully handling the request and returning the default 404 template for us.

Although this it being ran by Python's unittest framework this is what we would call an integration test. When we make the request with Client.get() the full Django request / response lifecycle is ran.

Let's add some code to our urls.py to make our URL work. Remember that we want to write the smallest amount of code we can to make the test pass.

We'll add the path (#1), create the view (#2) and return a HttpResponse (#3) to make our test happy.

# 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)
# 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
]

Execute the tests again to confirm that you've satisfied out test case.

> python manage.py test
Found 1 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.002s

OK
Destroying test database for alias 'default'...

We've just written our first piece of code that was test driven. Congratulations ๐Ÿ‘

Now we need to think about whether or not we should keep going or refactor some of this code. For now I think we should continue. What do you think?

The second piece of functionality we can tackle is that the API should return a JSON response. You're probably already thinking about how you will write the code to satisfy this, but what about the test?

The response object that the Django test client returns allows us to access the HTTP response headers as a dictionary. Thanks to this we can write a test confirming the Content-Type is application/json.

# 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)

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

When we run the tests we see that text/html doesn't equal application/json. Of course it doesn't!

The simplest way to make this test pass is to replace our HttpResponse with JsonResponse (#1, #2). Django's built-in JsonResponse will handle serialisation for us and make our life far simpler going forward.

# 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),
]

Should we refactor?

Yes! Let's remove that unused HttpResponse import and rearrange the imports to make the always pedantic isort happy.

Here's what we end up with:

# 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)

    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.http import JsonResponse
from django.urls import path

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

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

Great. We have two passing tests and we've just done our first refactor to make the code more readable.

Let's continue with the next part of this feature: returning the current_time key in the response.

The test case is again very simple (notice a trend?). Once we have the response, we use the .json() function to convert it to a dictionary, and the in operator to check for our expected key.

# 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)

    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())

All going well our tests fail for the correct reason here. The key "current_time" shouldn't exist in our JSON response.

Let's update our view to return the expected key (#1). For now there's no reason (test!) to return anything other than an empty string.

# 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),
]

Run the tests and make sure that they're passing. They should be! We're shaping up this response exactly how we want it.

Again think about refactoring this code. For now I think we're okay... It's your turn next so think about if there's anything you're interested in changing.

Now it's time to return an actual date. We need to format it the ISO 8601 format, and we can test that by parsing it in our test and confirming we receive a datetime object.

# 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)

    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))

When we run those tests we notice something interesting. The new test isn't failing but rather erroring.

You could take a moment to wrap our test's call to strptime in some exception handling but since the error we are receiving is directly related to the date format it's okay to leave it as is for now.

Think about the simplest code the we can write to make this pass for a moment. Do we need to return the current time for this test to pass? No, we don't.

Let's update the view to return a valid date. In this case, it's just an arbitrary date in the correct format.

# 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': '2007-10-10T08:00:00Z'
    })

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

What we did there was an example of writing the simplest piece of code that we needed to. Our test passes with flying colours since we aren't testing anything other than that we're returning a valid datetime.

At this point we could refactor to generalise the solution. There's a better option though: let's add a new test that forces us to generalise the solution.

By using the mocking library built into Python 3 we can know in advance the date that we'll return from a call to Django's timezone.now().

What we're about to do is called "patching the response" and works a like this:

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

By using that code in our test we know that the date returned by django.utils.timezone.now will be ten minutes past ten on January 1st, 2018.

Let's add a test that uses this pattern and forces us to generalise our solution.

# 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)

    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)

Run the tests and make sure it fails. It should complain that whatever time it is now isn't the same the datetime we started returning earlier.

Now we can switch to using timezone.now() (#1) to return the current time and strftime() (#2) to make sure it's formatted correctly.

# 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')  # 2
    })

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

Run the tests and make sure they're happy. At this point we've finished the features of the story. Nice one!

Let's think about refactoring though. There's a few things that I think we can make better.

Firstly, we can move the magic time formatting string into our settings (#1) and update our view to use the setting instead of having the string hard coded (#2, #3).

# time_api/settings.py

DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%SZ'  # 1
# time_api/urls.py
from django.conf import settings
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),
]

We run the tests and make sure that our change hasn't broken anything. Nope? All good, let's keep going.

While it's perfectly fine to have this view in our urls.py, the "Django-way" is to add views like this into their own apps. Let's do that now, making sure that our tests still pass along the way.

Firstly, let's create a new app called times.

(venv)> python manage.py startapp times

Then we migrate our view code into times/views.py and update time_api/urls.py to point at that view. We can use this as a chance to clean up the imports in our urls.py as well โ€“ most of those aren't needed any more.

# 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),
]

It's a good time to run the tests and make sure they're still being discovered and passing. Green? Great.

As a final step, let's move the tests into times/tests.py so that they are closer to the code that they're testing.

# 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)

Cool. Run the tests one last time to make sure that they're still being discovered. All tests passing? Time for a celebratory coffee โ˜•๏ธ!

Your turn

So, that's how I did it. Now it's your turn: write the same view by writing the tests first and seeing them fail.

Have a think about how you could use type hints to help with documenting this endpoint. Or perhaps what happens if the timezone in your settings isnโ€™t UTC? Is there a way you can test that?

You can complete this Kata with any language or framework, I'd love to hear about your approach!