Introduction
Times are changing, and data is king[1]. We usually store data within an RDBMS such as PostgreSQL or MySQL. Within a Django application, we manage and interface with our data using Django Models โ something that we have explored last time. But this is not enough; we need a way to expose an interface in which this data is to be interacted with. Seeing that we are interested in building web applications, we are at mercy of the HTTP protocol. So we expose API Endpoints that allows clients to programatically interface with our data.
In this article, we will explore how we can test these API Endpoints.
Endpoint Testing: Django REST Framework
Let's take a quick look at the Django REST Framework, which we'll refer as DRF from now on. As stated in their home page:
Django REST framework is a powerful and flexible toolkit for building Web APIs.
And I can say with confidence, and probably thousands of other developers as well, that it indeed lives up to its tagline. It offers a LOT of functionality that makes API development easier, such as Serializers and ViewSets. The documentation is superb, the community support is great, and the mental model of how everything fits just makes sense.
As for testing, DRF provides the APIClient
and APITestCase
classes. These classes extend Django's django.test.Client
and django.test.TestCase
, respectively. Both of these components work with their Django counterparts, but the extra goodies do come handy from time to time.
Let's proceed by creating the endpoints that we'll be writing our tests for.
Get them bread: DRF ViewSets and Serializers
Going against the spirit of TDD, let's write a bunch of endpoints that will allow clients to do BREAD Operations[2] against a Django Model. We'll create a Film model to supplement the previous article:
from django.db import models
class Film(models.Model):
title = models.CharField(max_length=255)
description = models.CharField(max_length=255)
release_year = models.DateField(auto_now=True)
def __str__(self):
return self.title
We'll be using a ModelSerializer for the presentation logic:
from restframework import serializers
from .models import Film
class FilmSerializer(serializers.ModelSerializer):
class Meta:
model = Film
fields = "__all__"
... and a ModelViewSet to implement the endpoints:
from restframework import viewsets
from .serializers import FilmSerializer
class FilmViewSet(viewsets.ModelViewSet):
queryset = Film.objects.all()
serializer = FilmSerializer
All that is left to do is to register the viewset to a URL mapping within the urls.py
file:
from .views import FilmViewSet
from rest_framework.routers import DefaultRouter
app_name = "films"
router = DefaultRouter()
router.register(r'films', FilmViewSet)
urlpatterns = router.urls
With just 20 or so lines of code, we already have:
- Extensible data representation for both presentation and data access.
- Complete REST-ful routing, with sensible route names for URL lookup.
What would automated tests look like for these API endpoints?
Test Browse and Read Endpoints
We'll be starting with the endpoints that fetches data for us:
from rest_framework import status
from rest_framework.test import APITestCase
from .models import Film
from .serializers import FilmSerializer
class FilmViewsTest(APITestCase):
@classmethod
def setUpTestData(cls):
# (1)
cls.films = [Film.objects.create() for _ in range(3)]
cls.film = cls.films[0]
def test_can_browse_all_films(self):
# (2)
response = self.client.get(reverse("films:film-list"))
# (3)
self.assertEquals(status.HTTP_200_OK, response.status_code)
self.assertEquals(len(self.films), len(response.data))
for film in self.films:
# (4)
self.assertIn(
FilmSerializer(instance=film).data,
response.data
)
def test_can_read_a_specific_film(self):
# (5)
response = self.client.get(
reverse("films:film-detail", args=[self.film.id])
)
self.assertEquals(status.HTTP_200_OK, response.status_code)
self.assertEquals(
FilmSerializer(instance=film).data,
response.data
)
This should hopefully all make sense as it's basically the same as testing a Django Model, or a Python class for that matter. The only difference is that instead of asserting the properties and methods of a class, we are interacting with its interface through HTTP. Let's take a closer look:
- We create a set of data to test with.
- We issue a
GET
request to the Film's "index" endpoint, which by convention will return all the available films in our system. Note that the reverse lookup name, aptly calledfilms:film-list
, is auto-generated by DRF'sViewSet
class. - The
response
contains data related to, well, the response of ourGET
request. We assert that it did return a200
, and the response body has the same number of items with the total films that we created. - Using the
FilmSerializer
class that we created earlier, we assert that each film in the response is properly formatted. - We do the same process for the test of reading a specific film, with the main difference that we are only checking a singular film instead of looping through a list of many.
Test Add and Edit Endpoints
The Add, Edit, and Delete endpoints share an important characteristic: they all modify the database state in one way or another. We'll group the Add and Edit endpoints together because they function similarly enough:
# ...
class FilmViewsTest(APITestCase):
# ...
def test_can_add_a_new_film(self):
# (1)
payload = {
"title": "2001: A Space Odyssey",
"description":
"The Discovery One and its revolutionary super " \
"computer seek a mysterious monolith that first " \
"appeared at the dawn of man.",
"release_year": 1968
}
response = self.client.post(reverse("films:film-list"), payload)
created_film = Film.objects.get(title=payload["title"])
self.assertEquals(status.HTTP_201_CREATED, response.status_code)
# (2)
for k, v in payload.items():
self.assertEquals(v, response.data[k])
self.assertEquals(v, getattr(created_film, k))
def test_can_edit_a_film(self):
# (3)
film_data = {
"title": "InceptioN",
"description":
"Cobb steals information from his targets by " \
"entering their dreams. Saito offers to wipe " \
"clean Cobb's criminal history as payment for " \
"performing an inception on his sick competitor's son.",
"release_year": 2001
}
film = Film.objects.create(**film_data)
# (4)
payload = {
"title": "Inception",
"release_year": 2010
}
response = self.client.patch(
reverse("film:film-detail", args=[new_film.id])
)
# (5)
film.refresh_from_db()
self.assertEquals(status.HTTP_200_OK, response.status_code)
for k, v in payload.items():
self.assertEquals(v, response.data[k])
self.assertEquals(v, getattr(film, k))
That's a mouthful. Take a minute to read through it. In a nutshell, we are accessing the create and update facilities of our Film model โ the difference is that it sits behind our API and we interact with it through HTTP verbs (POST
and PATCH
). We can see that:
- We prepare a payload of data that we would like to create a film with and
POST
it to thefilms:film-list
endpoint. - A neat trick: we loop through the keys in our payload and compare the response body. The tedious alternative is to write assertions for each field manually.
- As for the editing endpoint, we first create a film to test against.
- Then we
PATCH
through the fields that we would like to be updated against thefilms:film-detail
endpoint. - We then refresh the film instance and check if the changes have been persisted.
Test the Delete Endpoint
Lastly, let's see how we'll test the Delete endpoint:
# ...
class FilmViewsTest(APITestCase):
# ...
def test_can_delete_a_film(self):
response = self.client.delete(
reverse("films:film-detail", args=[self.film.id])
)
self.assertEquals(status.HTTP_204_NO_CONTENT, response.status_code)
self.assertFalse(Film.objects.filter(pk=self.film.id))
The code should be self-explanatory. It's a pity that there's no assertIsNotEmpty
available in the unittest module, so we have to resort to the closing assertFalse
call to check that the filtered queryset is empty. It logically works, but it violates the law of least surprise[3].
Conclusion
In this article, we have touched on the following points:
- What the Django REST Framework is, and how it helps us in API development and testing.
- Using
ModelViewSet
andModelSerializer
to build a set of REST-ful BREAD endpoints. - How to test each endpoints by issuing HTTP requests through an
APITestCase
.
I hope you get something out of this article. May your endpoints stay REST-ful and your breads loaves fresh. Stay safe!
Top comments (0)