DEV Community

Netervati
Netervati

Posted on • Updated on

Writing effective tests in Django & Django REST Framework

Overview

Let’s explore ways to write effective tests in Django and Django REST framework. We’ll cover the following topics in this post:

  • Test factory and Faker
  • Happy Paths, Exceptions, and Errors

Test factory and Faker

When writing tests that require data, we usually create the model's data first and then pass them in assertions:

blog = Blog.objects.create(title='Django is awesome')
self.assertEqual(blog.title, 'Django is awesome')
Enter fullscreen mode Exit fullscreen mode

While this is not a bad implementation, we should replace all declarations of model.objects.create using test factories. Why do this? One good answer is reusability. A test factory works the same way as a model but without the need to manually create the data in tests.

models.py

from django.db import models

class Blog(models.Model):
  content = models.TextField()
  summary = models.CharField(max_length=150)
  title = models.CharField(max_length=100)
Enter fullscreen mode Exit fullscreen mode

factories.py

from factory import Faker
from factory.django import DjangoModelFactory

class BlogFactory(DjangoModelFactory):
  class Meta:
      model = 'app.Blog'

  content = Faker('sentence', nb_words=12)
  summary = Faker('sentence', nb_words=4)
  title = Faker('sentence', nb_words=2)
Enter fullscreen mode Exit fullscreen mode

Here I'm using factory_boy to create the factory class for Blog. This package contains a wrapper for Faker, which we can use to create dummy data for our model fields. With this, test cases that require the use of Blog can now be written in a leaner fashion. Observe the example below:

tests.py

from app.factories import BlogFactory
from app.serializers import BlogSerializer

from rest_framework.test import APITestCase

class ReadBlogServiceTest(APITestCase):
  def setUp(self):
    # creates an accessible data in the service
    self.blog = BlogFactory()
    self.serializer = BlogSerializer(self.blog, many=False)
    self.response = self.client.get(f'/blogs/{self.blog.id}')

  def test_returns_blog(self):
    self.assertEqual(self.response.data, self.serializer.data)
Enter fullscreen mode Exit fullscreen mode

The data is created behind the scene by BlogFactory. This includes the model's id, which is why I can access it via self.blog.id. If we need to declare the model field's value manually, then we need to pass it as parameter to the factory:

BlogFactory(title='This is a different title')
Enter fullscreen mode Exit fullscreen mode

What about foreign keys? The same way as any model: create a factory for the child model. Then, instantiate it in the test case, and pass the id as parameter to the parent model's factory.

user = UserFactory()
BlogFactory(author=user.id)
Enter fullscreen mode Exit fullscreen mode

Now, we can write solid, less verbose test cases for our projects.

Happy Paths, Exceptions, and Errors

A significant aspect of testing is making sure that we capture the behaviors of what we are testing. Whether it is a granular part of the application or an entire workflow, it is important to test the expected result for each scenario. This is what I'm going to cover in this section.

Let's say we have an endpoint that retrieves a student based on its id. The id is passed as path parameter in the URL: /students/<str:id>, and is used to retrieve the associated record. The record is, then, serialized and returned in the response. Let's also add a rule that only students with the id between 1 and 50 can be retrieved, or else the endpoint will raise an exception.

from app.models import Student
from app.serializers import StudentSerializer

from rest_framework.exceptions import APIException
from rest_framework.views import APIView
from rest_framework.response import Response

class InvalidParameter(APIException):
    status_code = 406
    default_detail = 'The id passed is greater than 50.'
    default_code = 'invalid_parameter'

class ReadStudentService(APIView):
  def get(self, request, id):
    if id > 50:
      raise InvalidParameter

    student = Student.objects.get(pk=id)

    return Response(StudentSerializer(student, many=False).data)
Enter fullscreen mode Exit fullscreen mode

Given these criteria the first thing we do is stub the data in our test case. We're going to use StudentFactory to create our student record:

from app.factories import StudentFactory, UserFactory
from app.serializers import StudentSerializer

from rest_framework.test import APITestCase

class ReadStudentServiceTest(APITestCase):
  def setUp(self):
    user = UserFactory()
    self.student = StudentFactory(author=user.id)
    self.serializer = StudentSerializer(self.student, many=False)
    self.response = self.client.get(f'/students/{self.student.id}')
Enter fullscreen mode Exit fullscreen mode

Then, we proceed with happy path testing. What is a happy path? It is a scenario in an application featuring no exceptions or errors. In this example, the happy path is the endpoint returning the serialized student record. We can create three assertions here:

  1. Assert that the http status is 200
  2. Assert that the response returns a dictionary
  3. Assert that the response returns the student record
def test_request_successful(self):
  # here I'm using status from rest_framework
  self.assertEqual(self.response.status_code, status.HTTP_200_OK)

def test_returns_dictionary(self):
  self.assertEqual(isinstance(self.response.data, dict), True)

def test_returns_student(self):
  self.assertEqual(self.response.data, self.serializer.data)
Enter fullscreen mode Exit fullscreen mode

The advantage to writing all of these is that we are indirectly documenting the application's functionalities. From reading this test case alone, we know that when we pass a valid id in the student endpoint, it should respond successfully and return a dictionary of the student record. Note that we are also isolating each assertion in separate methods so that we can easily pinpoint failures when we run the test. This applies to exceptions and errors as well.

The endpoint will raise InvalidParameter exception if the id passed is greater than 50. We can simulate this scenario by using self.client.get and supply the path parameter with the value 51 or higher. Then, we can use assertRaises and pass the exception class:

def test_invalid_param_raises_error(self):
  self.client.get(`/students/51`)

  # the exception is imported from the service file
  self.assertRaises(InvalidParameter)
Enter fullscreen mode Exit fullscreen mode

Finally, let's identify the errors. Since we are testing an endpoint, we can think of the errors as http status codes for unsuccessful requests. We already have a scenario where the http status code is 406 if the endpoint raises InvalidParameter (see the class in the service). We can also add another scenario where the id passed does not match any student record. This will result to a 404 status code:

def test_request_with_invalid_params(self):
  response = self.client.get(`/students/51`)

  self.assertEqual(response.status_code, status.HTTP_406_NOT_ACCEPTABLE)

def test_request_not_found(self):
  response = self.client.get(`/students/10`)

  self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
Enter fullscreen mode Exit fullscreen mode

And we have finally covered the behaviors of the student endpoint.

Conclusion

What I have discussed above is but a fraction of creating tests. There are other components that I did not include in this post such as serializers, authentication, middlewares, and so on. However, the methods that I provided can definitely be utilized when approaching those components. I hope this helped you in some way and also made you appreciate the importance of writing tests for your projects.

Happy coding!

Top comments (0)