My name is Brenton Cleeland
I work at Thoughworks in Melbourne 🇦🇺
I've travelled a long way! 😴
Today
Format
So, what is TDD?
- Martin Fowler
🔴💚🔁
Refactor both your code and tests
Rules, you say?
- Robert Martin
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
As a user I want to receive the current UTC time so I can ensure my clock is correct
/api/time
endpoint should return a JSON response with a current_time
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)
🔴
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.
🔴💚🔁
/api/time
endpoint should return a JSON response with a current_time
keyHow 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:
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
/api/time/
endpoint should accept a timezone
query parameter that provides a timezone in the IANA "Australia/Melbourne" formattimezone
, offset
and current_time
fieldstimezone
field should include the name of the timezoneoffset
field should include the current offset for the timezone in ±hhmm
formatcurrent_time
field should include the current time in the specified timezoneerror
key should be provided in the JSONHow 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
/api/time/
endpoint should accept city
and country
parameterscurrent_time
, city
and country
fieldsHere'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 🙏