Blog
TDD with Django
- April 29, 2022
- Posted by: techjediadmin
- Category: Programming Testing
What TDD (test-driven development)?
Test Driven Development (TDD) is software development process, where requirements are converted to test cases first and implemented later. In simple terms, automated test cases for each functionality are created and tested first. The added test cases obviously fails, then we implement the functionality to pass the test case.
Why TDD?
Developer problem: It is not uncommon you see — developers having an attitude of ‘testing is not my job’ and resist to write any unit/integration tests. This kind of attitude is ok for a sample/example/demo application, where the changes to application is not expected after showing demo.
In reality most of the applications development under go a lot a changes during development phase and also after taking it to production. We will keep on adding new features and enhancing it. As your application grows, it becomes more difficult to grow and to refactor your code. There’s always the risk that a change in one part of your application will break another part. A good collection of automated tests that go along with an application can verify that changes you make to one part of the software do not break another.
TDD will ensure automated test suite is available for the application being developed. Also, with TDD – you’ll learn to break code into logical, easily understandable pieces and incrementally add complexity to system/application without breaking it.
Note: TDD doesn’t guarantee an error free code. However you will write better code, which results in better quality. Moreover TDD is practically industry standard and it extremely goes well in modern Agile way of software development (iterative development).
Cons with TDD:
On the other side TDD typically adds 10–30% of your development costs. This is the main reason organizations and developers don’t use TDD. But for a long running project — TDD definitely saves a lot of time and money.
TDD cycle: Red-Green-Grey (Fail-Pass-Refactor):
In TDD we get into a cycle, where we do the following 3 steps repeatedly
- Write test code, make it fail
- Start writing just enough production code to make the test work
- Refactor the production code — if needed (optional step)
TDD based REST API development with Django
Requirement:
In a RESTful API, endpoints (URLs) define the structure of the API and how end users access data using the HTTP methods — GET, POST, PUT, DELETE. Let us develop APIs for a single resource puppies
with following URLs.
What we will be doing — TDD Cycle?
- add a unit test – just enough code to fail
- Add code to make the above test pass (and skip the refactor step as this is a sample application)
Once the test passes, start over with the same process with a new test.
Step 1: UT setup
Create a new file: /puppies/tests/test_views.py for the unit tests for our views and create a new test client for our app as below:
import json
from rest_framework import status
from django.test import TestCase, Client
from django.urls import reverse
from ..models import Puppy
from ..serializers import PuppySerializer
# initialize the APIClient app
client = Client()
Step 2: Skeleton APIs setup
Let us first create a skeleton of all view functions that return empty responses and map them with their appropriate URLs within the /puppies/views.py file:
from rest_framework.decorators import api_view
from rest_framework.response import Response
from rest_framework import status
from .models import Puppy
from .serializers import PuppySerializer
@api_view(['GET', 'DELETE', 'PUT'])
def get_delete_update_puppy(request, pk):
try:
puppy = Puppy.objects.get(pk=pk)
except Puppy.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND) # get details of a single puppy
if request.method == 'GET':
return Response({})
# delete a single puppy
elif request.method == 'DELETE':
return Response({})
# update details of a single puppy
elif request.method == 'PUT':
return Response({})
@api_view(['GET', 'POST'])
def get_post_puppies(request):
# get all puppies
if request.method == 'GET':
return Response({})
# insert a new record for a puppy
elif request.method == 'POST':
return Response({})
Step 3: Add route details in /puppies/urls.py file:
from django.conf.urls import url
from . import views
urlpatterns = [
url(
r'^api/v1/puppies/(?P<pk>[0-9]+)$',
views.get_delete_update_puppy,
name='get_delete_update_puppy'
),
url(
r'^api/v1/puppies/$',
views.get_post_puppies,
name='get_post_puppies'
)
]
Update /puppy_store/urls.py as well:
from django.conf.urls import include, url
from django.contrib import adminurlpatterns = [
url(r'^', include('puppies.urls')),
url(
r'^api-auth/',
include('rest_framework.urls', namespace='rest_framework')
),
url(r'^admin/', admin.site.urls),
]
Step 4 (a): Add test : GET ALL
Lets code to add test case to verify all records:
class GetAllPuppiesTest(TestCase):
""" Test module for GET all puppies API """ def setUp(self):
Puppy.objects.create(
name='Casper', age=3, breed='Bull Dog', color='Black')
Puppy.objects.create(
name='Muffin', age=1, breed='Gradane', color='Brown')
Puppy.objects.create(
name='Rambo', age=2, breed='Labrador', color='Black')
Puppy.objects.create(
name='Ricky', age=6, breed='Labrador', color='Brown') def test_get_all_puppies(self):
# get API response
response = client.get(reverse('get_post_puppies'))
# get data from db
puppies = Puppy.objects.all()
serializer = PuppySerializer(puppies, many=True)
self.assertEqual(response.data, serializer.data)
self.assertEqual(response.status_code, status.HTTP_200_OK)
Run the test. You should see the following error:
self.assertEqual(response.data, serializer.data)
AssertionError: {} != [OrderedDict([('name', 'Casper'), ('age',[687 chars])])]
Step 4 (b): Add implementation : GET ALL
Update the view and make sure the test case passes.
@api_view(['GET', 'POST'])
def get_post_puppies(request):
# get all puppies
if request.method == 'GET':
puppies = Puppy.objects.all()
serializer = PuppySerializer(puppies, many=True)
return Response(serializer.data)
# insert a new record for a puppy
elif request.method == 'POST':
return Response({})
Run the tests to ensure they all pass:
Ran 2 tests in 0.072sOK
Step 5 (a): Add test : GET Single
Fetching a single puppy involves two test cases:
- Get valid puppy — e.g., the puppy exists
- Get invalid puppy — e.g., the puppy does not exists
Add the tests:
class GetSinglePuppyTest(TestCase):
""" Test module for GET single puppy API """ def setUp(self):
self.casper = Puppy.objects.create(
name='Casper', age=3, breed='Bull Dog', color='Black')
self.muffin = Puppy.objects.create(
name='Muffin', age=1, breed='Gradane', color='Brown')
self.rambo = Puppy.objects.create(
name='Rambo', age=2, breed='Labrador', color='Black')
self.ricky = Puppy.objects.create(
name='Ricky', age=6, breed='Labrador', color='Brown') def test_get_valid_single_puppy(self):
response = client.get(
reverse('get_delete_update_puppy', kwargs={'pk': self.rambo.pk}))
puppy = Puppy.objects.get(pk=self.rambo.pk)
serializer = PuppySerializer(puppy)
self.assertEqual(response.data, serializer.data)
self.assertEqual(response.status_code, status.HTTP_200_OK) def test_get_invalid_single_puppy(self):
response = client.get(
reverse('get_delete_update_puppy', kwargs={'pk': 30}))
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
Run the tests. You should see the following error:
self.assertEqual(response.data, serializer.data)
AssertionError: {} != {'name': 'Rambo', 'age': 2, 'breed': 'Labr[109 chars]26Z'}
Step 5 (b): Add implementation : GET Single
Update the view:
@api_view(['GET', 'UPDATE', 'DELETE'])
def get_delete_update_puppy(request, pk):
try:
puppy = Puppy.objects.get(pk=pk)
except Puppy.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND) # get details of a single puppy
if request.method == 'GET':
serializer = PuppySerializer(puppy)
return Response(serializer.data)
Run the tests to ensure they all pass.
Ran 4 tests in 0.142sOK
Step 6 (a): Add test : POST (Create)
Inserting a new record involves two cases as well:
- Inserting a valid puppy
- Inserting a invalid puppy
First, write tests for it:
class CreateNewPuppyTest(TestCase):
""" Test module for inserting a new puppy """ def setUp(self):
self.valid_payload = {
'name': 'Muffin',
'age': 4,
'breed': 'Pamerion',
'color': 'White'
}
self.invalid_payload = {
'name': '',
'age': 4,
'breed': 'Pamerion',
'color': 'White'
} def test_create_valid_puppy(self):
response = client.post(
reverse('get_post_puppies'),
data=json.dumps(self.valid_payload),
content_type='application/json'
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED) def test_create_invalid_puppy(self):
response = client.post(
reverse('get_post_puppies'),
data=json.dumps(self.invalid_payload),
content_type='application/json'
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
Run the tests. You should see two failures:
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
AssertionError: 200 != 400self.assertEqual(response.status_code, status.HTTP_201_CREATED)
AssertionError: 200 != 201
Step 6 (b): Add implementation : POST (Create)
Update the view to get the tests to pass:
@api_view(['GET', 'POST'])
def get_post_puppies(request):
# get all puppies
if request.method == 'GET':
puppies = Puppy.objects.all()
serializer = PuppySerializer(puppies, many=True)
return Response(serializer.data)
# insert a new record for a puppy
if request.method == 'POST':
data = {
'name': request.data.get('name'),
'age': int(request.data.get('age')),
'breed': request.data.get('breed'),
'color': request.data.get('color')
}
serializer = PuppySerializer(data=data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
Here, we inserted a new record by serializing and validating the request data before inserting to the database.
Run the tests to ensure they all pass.
Ran 6 tests in 0.242sOK
Step 7 (a): Add test : PUT (Update)
Start with a test to update a record. Similar to adding a record, we again need to test for both valid and invalid updates:
class UpdateSinglePuppyTest(TestCase):
""" Test module for updating an existing puppy record """ def setUp(self):
self.casper = Puppy.objects.create(
name='Casper', age=3, breed='Bull Dog', color='Black')
self.muffin = Puppy.objects.create(
name='Muffy', age=1, breed='Gradane', color='Brown')
self.valid_payload = {
'name': 'Muffy',
'age': 2,
'breed': 'Labrador',
'color': 'Black'
}
self.invalid_payload = {
'name': '',
'age': 4,
'breed': 'Pamerion',
'color': 'White'
} def test_valid_update_puppy(self):
response = client.put(
reverse('get_delete_update_puppy', kwargs={'pk': self.muffin.pk}),
data=json.dumps(self.valid_payload),
content_type='application/json'
)
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) def test_invalid_update_puppy(self):
response = client.put(
reverse('get_delete_update_puppy', kwargs={'pk': self.muffin.pk}),
data=json.dumps(self.invalid_payload),
content_type='application/json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
Run the tests.
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
AssertionError: 405 != 400self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
AssertionError: 405 != 204
Step 7 (b): Add implementation : PUT (Update)
Update the view:
@api_view(['GET', 'DELETE', 'PUT'])
def get_delete_update_puppy(request, pk):
try:
puppy = Puppy.objects.get(pk=pk)
except Puppy.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND) # get details of a single puppy
if request.method == 'GET':
serializer = PuppySerializer(puppy)
return Response(serializer.data) # update details of a single puppy
if request.method == 'PUT':
serializer = PuppySerializer(puppy, data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_204_NO_CONTENT)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) # delete a single puppy
elif request.method == 'DELETE':
return Response({})
Run the tests again to ensure that all the tests pass.
Step 8 (a): Add test : DELETE
To delete a single record, an ID is required:
class DeleteSinglePuppyTest(TestCase):
""" Test module for deleting an existing puppy record """ def setUp(self):
self.casper = Puppy.objects.create(
name='Casper', age=3, breed='Bull Dog', color='Black')
self.muffin = Puppy.objects.create(
name='Muffy', age=1, breed='Gradane', color='Brown') def test_valid_delete_puppy(self):
response = client.delete(
reverse('get_delete_update_puppy', kwargs={'pk': self.muffin.pk}))
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) def test_invalid_delete_puppy(self):
response = client.delete(
reverse('get_delete_update_puppy', kwargs={'pk': 30}))
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
Run the tests. You should see:
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
AssertionError: 200 != 204
Step 8 (b): Add implementation : DELETE
Update the view:
@api_view(['GET', 'DELETE', 'PUT'])
def get_delete_update_puppy(request, pk):
try:
puppy = Puppy.objects.get(pk=pk)
except Puppy.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND) # get details of a single puppy
if request.method == 'GET':
serializer = PuppySerializer(puppy)
return Response(serializer.data) # update details of a single puppy
if request.method == 'PUT':
serializer = PuppySerializer(puppy, data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_204_NO_CONTENT)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) # delete a single puppy
if request.method == 'DELETE':
puppy.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
Run the tests again. Make sure all of them pass.
Reference:
https://en.wikipedia.org/wiki/Test-driven_development
https://realpython.com/test-driven-development-of-a-django-restful-api/
https://arunk2.medium.com/tdd-with-django-53f71849580f
Read Similar Blogs: