Ademola Thompson
Posted on April 5, 2024
More often than not, you would encounter a bug when your app is in production. Sometimes the bug is easy to fix, sometimes it’s not. But many times, after shipping the fix, you realize that a different part of your app is broken.
This is very common for developers. However, it is possible to automate your code to catch new errors that might arise from fixing different errors. You do this by writing tests.
In programming, writing tests is a way to ensure that your code works as expected, and when it doesn’t work as expected, you will have all the information you need to track down the bug without wasting time.
Django REST Framework is a high-level Python framework that allows you to write REST APIs with relatively minimal stress. When you build APIs, it is important to write tests that will ensure your endpoints behave as expected.
In this tutorial, you will learn about the different types of testing, and how to write unit tests; the most basic form of tests.
As a developer, you should learn how to write tests because they improve the quality of your code.
Prerequisites
This article assumes that you already have adequate knowledge of the following concepts:
Python and general programming concepts
Django concepts such as apps, projects, folder structure, etc.
Concepts of Django REST Frameworks such as views, serializers, and URLs.
Types of Tests in Software Development
In software development, there are different types of tests that you can write. Each one serves its purpose. It is important to understand the different types of tests so you can decide which one to write given a particular scenario.
Generally speaking, there are two broad categories of tests in software development:
Functional tests: These types of tests help you ensure the functionalities of your app are working well. For example, you might want to test whether an API endpoint returns the right status code or response body. You might also want to test if your custom model methods are working properly.
Non-functional tests: These tests focus on other important parts of your app such as performance, usability, and security.
This tutorial focuses mainly on functional tests. These are some of the most popular functional tests in software development. While there are other types of tests, these are very popular among developers and software teams.
Unit Tests
Unit tests are the most basic forms of functional testing. Writing unit tests involves testing the individual components or units of your application to ensure they exhibit expected behavior.
In unit testing, your focus will be to ensure that your app's individual parts (or units) do their jobs as expected. You don’t have to worry about how they interact with each other. A unit can be anything from a function to a class in your program.
Integration Tests
Integration tests check if two or more components or modules of your application relate to each other properly. For example, in an e-commerce application, you will have multiple modules such as the product catalog, shopping cart, and payment gateway.
An integration test for this scenario will be to ensure that when a product is added from the product catalog, it reflects properly in the user’s shopping cart.
Regression Tests
Regression tests aim to prevent your code from breaking after you make changes to it. For instance, if you update a certain feature, a regression test will check to ensure this update does not affect other features of your app.
End-to-End Tests
End-to-end tests aim to test every aspect of your software from beginning to end. This kind of test aims to simulate a user’s interaction with your software or app and ensure there are no errors.
For instance, if you have a social media application, an end-to-end test will test the entire flow from user onboarding to creating posts and interacting with other people’s posts.
How to Write Unit Tests in Django REST Framework
There are multiple ways to write unit tests in Django REST Framework (DRF). One way is to use the built-in testing architecture provided by DRF. Although this method is effective, it does not offer much ability for customization. This means if you have a unique testing requirement, you might face some problems getting it done easily.
Another way to write unit tests in DRF is by using a 3rd party framework such as pytest-django. It offers more flexibility than DRF’s built-in test modules. The official documentation talks about why you should use pytest-django over DRF’s test modules.
The next steps will show you how to write unit tests using pytest-django and explain some of its concepts and features.
NOTE: All the code used in this tutorial is available on GitHub
Step 1: Install and Configure Pytest-Django for Your Project
Use this command to install pytest-django in your virtual environment:
pip install pytest-django
The command above will install pytest-django and all its dependencies into your project’s virtual environment. You don’t need to add anything to your installed apps in Django settings. After installation, you should follow these steps to configure your project for pytest-django:
-
Create a
pytest.ini
file. This file should be at the root of your project directory (where yourmanage.py
file is located). Thepytest.ini
file is a configuration file that allows you to define the behavior or configurations of each test whenever you run them. Without a configuration file, you will have to define things like environment variables and whether or not you want to log information about your tests whenever you run them individually. This is time-wasting, so ensure you use thepytest.ini
file.For this tutorial, paste this code into your
pytest.ini
file:
[pytest] DJANGO_SETTINGS_MODULE = core.settings # -- recommended but optional: python_files = tests.py test_*.py *_tests.py log_cli = 1 log_cli_level = INFO log_cli_format = %(asctime)s %(levelname)s %(message)s log_cli_date_format = %Y-%m-%d %H:%M:%S
The code above defines the settings module that pytest-django should use for testing. It also defines the Python files for tests. Lastly, it defines how information should be logged during tests.
NOTE: Change the value of
DJANGO_SETTINGS_MODULE
to your project’s settings module. Create a
conftest.py
file. Theconftest.py
file should be in the test directory of your app. You typically use this file to share something called fixtures, across different unit tests. Fixtures are functions that provide consistent and reusable setups for your tests. For instance, if you want to write multiple tests that involve creating user data, you can use fixtures to create the user data once and use it in any test file you want. Theconftest.py
file is not mandatory, but it is very necessary and effective.
Step 2: Adjust Your Project’s Folder Structure
After you set up pytest-django, you should adjust your project structure for ease. There are multiple ways to do this, and you can decide what works for you.
Django’s default folder structure looks similar to this:
project/
├─ manage.py
├─ project/
├─ app1/
│ ├─ tests.py
│ └─ ...
├─ app2/
│ ├─ tests.py
│ └─ ...
└─ ...
The structure above shows that Django provides a single test file within each app. You can decide to stick with this approach but it isn’t the best approach when you have a lot of scenarios and units to test. If you have to make changes to your test code, you might find yourself scrolling endlessly due to the large amount of code in the file. It’s better to look into more organized approaches.
One way to structure your project for tests is to have a base test folder at the same level as your manage.py
file. This method is very useful when you have common utility functions that your different apps share. It also makes it easier to create test scenarios that require units from different apps. This is what such a structure will generally look like:
project/
├─ manage.py
├─ pytest.ini
├─ project/
├─ app1/
├─ app2/
├─ tests/
│ ├─ conftest.py
│ ├─ app1_tests.py
│ ├─ app2_tests.py
This method also has its disadvantages because the tests folder might become larger and more complex to maintain as your project grows. If you’re comfortable with this method, you can use it to structure your project for tests.
Another method to structure your project for tests is by creating a test folder inside each app. Your folder structure will look like this:
project/
├─ manage.py
├─ pytest.ini
├─ project/
├─ app1/
│ ├─ tests/
│ │ ├─ conftest.py
│ │ ├─ test_views.py
│ │ ├─ test_models.py
│ │ └─ ...
├─ app1/
│ ├─ tests/
│ │ ├─ conftest.py
│ │ ├─ test_views.py
│ │ ├─ test_models.py
│ │ └─ ...
The obvious advantages of this structure are modularity and isolation because each application has its unique tests within it and this allows for better code organization overall.
On the flip side, this structure will be limited if you have common utility functions to share between apps. You might have to rewrite the same logic multiple times.
The folder structure you should choose should always depend on your project needs. It is also important to take programming principles such as DRY into account.
Lastly, it is possible to create a combination of both structures in the same project if you have to. This way, app-specific tests are written within the apps, and tests that involve multiple apps are written outside the apps.
Whatever approach you choose, ensure you include a __init__.py
file within the folder so it's treated as a Python package.
Step 3: Write Tests for Your Models
When writing tests, one of the questions you will often ask is, “What do I test?”, especially if you’re using a framework like DRF that has a lot of boilerplates and built-in features. The simple answer is to test every aspect of your own code, excluding functionalities provided as part of the Django REST Framework.
Consider this model for a project management API:
from django.db import models
from django.contrib.auth import get_user_model
User = get_user_model()
class Project(models.Model):
name = models.CharField(max_length=100)
description = models.TextField()
start_date = models.DateField()
end_date = models.DateField()
team_members = models.ManyToManyField('auth.User', related_name='projects')
def __str__(self):
return self.name
def get_project_duration(self):
duration = self.end_date-self.start_date
return duration.days
def get_team_members_count(self):
return self.team_members.count()
In the model above, you don’t need to write tests to ensure the name
field is saved as a CharField
or the start_date
is saved as a date field, because they are built-in features of Django. You can be assured they will work as expected.
However, you should write a test to ensure the name
field does not exceed 100 characters because it is your custom implementation. You should also write tests to ensure the custom methods in the model return the expected values.
These steps will show you how you can write tests for the model above:
Create a
test_models.py
file in your tests folder. This file will contain all the tests related to your models.-
Create fixtures in your
conftest.py
file. In pytest-django, you define fixtures with the@pytest.fixture
decorator. Here are two simple fixtures you can create:
# conftest.py import pytest from datetime import date from django.contrib.auth import get_user_model from projects.models import Project User = get_user_model() # create a user object @pytest.fixture def user() -> User: return User.objects.create_user(username="testuser", password="testpassword") # create a project object @pytest.fixture def project() -> Project: return Project.objects.create( name='Test Project', description='Test project description', start_date=date(2024, 3, 1), end_date=date(2024, 4, 1), )
The code snippet above contains two fixtures. The first fixture is called
user
and it simply returns a newly created user instance.
The second fixture is calledproject
and it creates a new object of the project model above.NOTE: When you define a pytest fixture, you can pass the fixture as a parameter to any of your test functions. For instance, if you have a test that checks the
get_project_duration
method in your model, you can pass theproject
fixture as a parameter to your test function like this:
def test_get_project_duration(project) -> None: # use the project fixture as a parameter # write your code logic pass
-
Write tests to test the custom methods of your model. In testing, one of your primary assignments is to ensure that your methods or endpoints return the expected value or output. To write tests for the custom methods in the Project model, you should create a scenario that calls these methods with specific values, and then compare the output you get to the output you’re expecting. Here is a simple example:
# test_models.py import pytest @pytest.mark.django_db def test_project_duration(project) -> None: # use the project fixture as a param assert project.get_project_duration() == 31 @pytest.mark.django_db def test_team_members_count(project, user) -> None: project.team_members.add(user) assert project.get_team_members_count() == 1
In the above code snippet, the
test_project_duration
function uses theproject
fixture. This means it does not have to create a new project instance. Finally, it checks to see if theget_project_duration
method returns the expected value of 31 since 2024-03-01 minus 2024-04-01 is 31 days (as defined in the project fixture).If you’re having trouble grasping this, try revisiting the Project model and the
project
fixture to understand the logic.The
test_team_members_count
function uses both theproject
and theuser
fixtures. First, it adds the created user as a team member and then it checks to ensure the count function returns 1 as the output since you have added only one user as a team member.NOTE: The
pytest.mark.django_db
decorator simply tells pytest that the test requires a database to be set up. -
Write a test for the character limit on the
CharField
. You want to ensure that the character limit of your name field is enforced by Django. This test is a bit tricky, so I suggest you try to write it on your own first to see how well you can get it done. Here’s a code snippet to test the character limit of the name field:
# test_models.py import pytest from django.core.exceptions import ValidationError from projects.models import Project @pytest.mark.django_db def test_project_name_character_count(project) -> None: project.name = 'A' * 256 # update project name to exceed max_length constraint with pytest.raises(ValidationError) as e: project.full_clean() assert 'Ensure this value has at most 100 characters' in str(e.value)
Before explaining the code, you should understand how Django performs validation for character fields. Django performs validation with the
full_clean
method. If there is a violation of the character limit, it raises aValidationError
to alert you. The validation error often contains a message like, “Ensure this value has at most {max_length} characters”The code snippet above attempts to recreate this scenario. First, it updates the project name to exceed the character limit. Next, it calls the
full_clean
method and raises a validation error if it fails. Finally, it checks the message of the validation error to confirm that it contains the right message. With these measures in place, your tests should work as expected. -
Run your tests. You only need to type this command in your CLI to run your tests:
pytest
With that command, pytest-django will search for all the test files with the formats you have specified in your
pytest.ini
file:
python_files = tests.py test_*.py *_tests.py
If you have followed the above steps, you should see something like this in your terminal:
Step 4: Write Tests for Your POST and GET Endpoints
After writing tests for your models, you should write your views and test them properly. Here is a simple view for creating and listing projects:
from rest_framework.generics import GenericAPIView
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework import status
from .models import Project
from .serializers import ProjectSerializer
class ProjectListCreateView(GenericAPIView):
serializer_class = ProjectSerializer
def get(self, request:Request):
projects = Project.objects.all()
serializer = self.serializer_class(instance=projects, many=True)
response = {
"message":"successful",
"data":serializer.data
}
return Response(data=response, status=status.HTTP_200_OK)
def post(self, request:Request):
serializer = self.serializer_class(data=request.data)
if serializer.is_valid():
serializer.save()
response = {
"message":"successful",
"data":serializer.data
}
return Response(data=response, status=status.HTTP_201_CREATED)
response = {
"message":"failed",
"data":serializer.errors
}
return Response(data=response, status=status.HTTP_400_BAD_REQUEST)
The code above is self-explanatory, so it's best to jump into writing tests for it. Ensure you create an appropriate serializer and URL pattern for your view. You can revisit the code on GitHub if you need to.
These steps will show you how to write the tests for the view above:
-
Open your
conftest.py
file, and paste these fixtures in it:
# conftest.py from rest_framework.test import APIClient # new import @pytest.fixture() def api_client() -> APIClient: """ Fixture to provide an API client """ yield APIClient() @pytest.fixture def project_payload(user) -> dict: # uses the user fixture return { "name": "New project", "description": "Test project description", "start_date": "2024-01-01", "end_date": "2024-02-01", "team_members": [user.id] }
The first fixture initializes the
APIClient
class in Django. This means you don’t have to manually initialize it whenever you want to test a new endpoint. TheAPIClient
is a class that DRF provides to make testing easier.The second fixture is called
project_payload
. It takes theuser
fixture as a parameter and returns sample data that you can use for testing operations such as POST requests. -
Create a file called
test_views.py
to contain the tests related to your views. The approach to writing a test for the view defined earlier is pretty simple. First, you should send a POST request to the endpoint with data to create a new project. After doing this, you should check if your project has been created by comparing the received status code with the expected status code. You should also compare the fields from the project that gets created with the ones from your raw data. For instance, you can compare the name of the project. Finally, you should send a GET request to the right endpoint and repeat the same steps. Here’s what your test should look like:
# test_views.py import pytest import logging logger = logging.getLogger(__name__) @pytest.mark.django_db def test_create_project(api_client, project_payload) -> None: # create a new project response_create = api_client.post('/api/project/', data=project_payload, format="json") logger.info(f"{response_create.data}") assert response_create.status_code == 201 assert response_create.data['data']['name'] == project_payload['name'] # read the newly created project response_read = api_client.get('/api/project/', format="json") assert response_read.status_code == 200 assert response_read.data['data'][0]['name'] == project_payload['name']
The test above utilizes both the
api_client
andproject_payload
fixtures.The function makes a POST request to the endpoint using the data returned by the
paroject_payload
. It logs the information about the data created. After that, it compares the status code with the expected status code. In the view, a status code of 201 is sent after a successful POST request.Next, it compares the name field of the newly created project with the name field of the data returned by the
project_payload
fixture. They have to be the same for the test to pass. The exact syntax you will use to access your fields depends on your API structure. For instance, theProjectListCreateView
defines the API structure like this:
{ "message":"successful", "data": serializer.data }
Therefore, to access any field, you should access the “data” key first. Here’s an example:
response_create.data['data']['name']
After confirming the POST action works as expected, the test sends a GET request and asserts both the status code and name field of the data in a similar fashion as done in the POST request.
Step 5: Write Tests for Your Update Endpoints
Writing tests for your update and delete endpoints follows a similar pattern as the previous tests. You should start by writing an appropriate view for your endpoint:
# views.py
class ProjectRetrieveUpdateDeleteView(GenericAPIView):
serializer_class = ProjectSerializer
def get(self, request:Request, project_id:int):
project = get_object_or_404(Project, id=project_id)
serializer = self.serializer_class(instance=project)
response = {
"message":"successful",
"data":serializer.data
}
return Response(data=response, status=status.HTTP_200_OK)
def patch(self, request:Request, project_id:int):
project = get_object_or_404(Project, id=project_id)
serializer = self.serializer_class(instance=project, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
response = {
"message":"successful",
"data":serializer.data
}
return Response(data=response, status=status.HTTP_201_CREATED)
response = {
"message":"failed",
"info":serializer.errors
}
return Response(data=response, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request:Request, project_id:int):
project = get_object_or_404(Project, id=project_id)
project.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
Ensure you have your URL pattern and serializer set up properly.
After creating your endpoint, you should start by writing tests for the update endpoint. The logic behind this is to first create a project like you did in the test_create_project
test. After you create the project, you should update a part of your payload and then send a PATCH request with this updated payload. Next, you should assert things like the status code and the value of the updated payload.
Finally, you should write a test for the case where the URL returns a 404 error.
Here’s what your code will look like:
# test_views.py
@pytest.mark.django_db
def test_update_project(api_client, project_payload) -> None:
# create a project
response_create = api_client.post('/api/project/', data=project_payload, format="json")
project_id = response_create.data["data"]["id"]
logger.info(f"Successfullly created project with ID {project_id}")
assert response_create.status_code == 201
assert response_create.data['data']['name'] == project_payload['name']
# update the project
project_payload["name"]="Updated project name"
response_update = api_client.patch(f'/api/project/modify/{project_id}/', data=project_payload, format="json")
new_title = response_update.data['data']['name']
logger.info(f"Successfullly updated project with ID {project_id}")
logger.info(f"new project title is {new_title}")
assert response_update.status_code == 201
assert response_update.data['data']['name'] == project_payload['name']
# project not found
response_update = api_client.patch(f'/api/project/modify/{project_id+20}/', data=project_payload, format="json")
assert response_update.status_code == 404
logger.info(f"Cound not find project with id {project_id+20}")
Step 6: Write Tests for Your Delete Endpoint
The final endpoint to test is the delete endpoint. The logic behind this test is simple.
First, you create a new project using the project_payload
fixture. Next, you delete the project by sending a DELETE request to the endpoint.
After you send the DELETE request, you should compare the outputs such as the status code. Next, you should check if the project was deleted by sending a GET request to retrieve the deleted project. This should return a 404 if everything works well.
Finally, you can decide to write a test for a scenario where the project was never found.
Here’s what the code will look like:
# test_views.py
@pytest.mark.django_db
def test_delete_project(api_client, project_payload):
# create a project
response_create = api_client.post('/api/project/', data=project_payload, format="json")
project_id = response_create.data["data"]["id"]
logger.info(f"Successfullly created project with ID {project_id}")
assert response_create.status_code == 201
# delete project
response_delete = api_client.delete(f"/api/project/modify/{project_id}/", data=project_payload, format="json")
logger.info(f"Deleted task with ID {project_id}")
assert response_delete.status_code == 204
# Read the project to ensure it was deleted
response_read = api_client.get(f"/api/project/modify/{project_id}/", format="json")
assert response_read.status_code == 404
# project not found
response_delete = api_client.delete(f'/api/project/modify/{project_id+20}/', data=project_payload, format="json")
assert response_delete.status_code == 404
logger.info(f"Cound not find project with id {project_id+20}")
If you type the pytest
command in your CLI, your tests will run and you should see something similar to this:
If you have followed this guide until now, congratulations! You should now have basic knowledge about writing unit tests in DRF. With some practice, you should be able to reproduce these steps easily.
How to Write Unit Tests For Endpoints That Require Permission
So far, this guide has taught you how to write tests for CRUD endpoints without any permission level. However, it is a good idea to know how to write tests for endpoints that require permissions.
To do this, edit the ProjectListCreateView
view to include a permission class:
# views.py
from rest_framework.permissions import IsAuthenticatedOrReadOnly # new import
class ProjectListCreateView(GenericAPIView):
serializer_class = ProjectSerializer
permission_classes = [IsAuthenticatedOrReadOnly]
...
This new update means only authenticated users can send POST requests to the endpoint.
In your test, you have to find a way to authenticate a user whenever you create a post request. The easiest way to do this is by using the force_authenticate
method available in the APIClient class DRF provides. Here’s an example of how your test should look like:
@pytest.mark.django_db
def test_create_project_authenticated(api_client, project_payload, user):
# authenticate the user
api_client.force_authenticate(user=user)
# send a post request with the authenticated user
response_create = api_client.post('/api/project/', data=project_payload, format="json")
assert response_create.status_code == 201
In the above test, the force_authenticate
method allows your test function to simulate a situation whereby an authenticated user is making the request. To run this test in isolation, you can use this command in your CLI:
pytest path/to/test_file.py::test_create_project_authenticated
If you’re using the code in the Github repo, your command should be this:
pytest projects/tests/test_views.py::test_create_project_authenticated
Now that you know how to write tests for endpoints with permissions, you can update your other test methods accordingly.
Conclusion
Writing tests for your code can be a bit challenging if you’re new to it. However, with regular practice and research, you should get comfortable with it after a while. This article has explained how to write unit tests for basic CRUD applications. You should build on this knowledge to write more complex tests.
When you write tests, always try to maintain good programming principles such as DRY, and SOLID. Ensure you avoid writing “dirty” test code at all costs.
Additional Reading
The following articles will help broaden your knowledge about writing tests in DRF with pytest-django:
Posted on April 5, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.