What to test in Django: Endpoints

alchermd

John Alcher

Posted on August 17, 2020

What to test in Django: Endpoints

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
Enter fullscreen mode Exit fullscreen mode

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__"
Enter fullscreen mode Exit fullscreen mode

... 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
        )
Enter fullscreen mode Exit fullscreen mode

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:

  1. We create a set of data to test with.
  2. 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 called films:film-list, is auto-generated by DRF's ViewSet class.
  3. The response contains data related to, well, the response of our GET request. We assert that it did return a 200, and the response body has the same number of items with the total films that we created.
  4. Using the FilmSerializer class that we created earlier, we assert that each film in the response is properly formatted.
  5. 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))
Enter fullscreen mode Exit fullscreen mode

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:

  1. We prepare a payload of data that we would like to create a film with and POST it to the films:film-list endpoint.
  2. 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.
  3. As for the editing endpoint, we first create a film to test against.
  4. Then we PATCH through the fields that we would like to be updated against the films:film-detail endpoint.
  5. 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))
Enter fullscreen mode Exit fullscreen mode

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 and ModelSerializer 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!

Footnotes

💖 💪 🙅 🚩
alchermd
John Alcher

Posted on August 17, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related