Netervati
Posted on June 4, 2022
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')
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)
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)
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)
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')
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)
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)
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}')
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:
- Assert that the http status is 200
- Assert that the response returns a dictionary
- 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)
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)
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)
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!
Posted on June 4, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.