DEV Community

Cover image for A Practical Approach to Unit Testing in Django REST Framework
Ademola Thompson for MLSA UNILAG

Posted on • Originally published at mlsaunilag.hashnode.dev

A Practical Approach to Unit Testing in Django REST Framework

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

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:

  1. Create a pytest.ini file. This file should be at the root of your project directory (where your manage.py file is located). The pytest.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 the pytest.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.

  2. Create a conftest.py file. The conftest.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. The conftest.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
│  └─ ...
└─ ...
Enter fullscreen mode Exit fullscreen mode

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

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

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

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:

  1. Create a test_models.py file in your tests folder. This file will contain all the tests related to your models.

  2. 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 called project 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 the project 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
    
  3. 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 the project fixture. This means it does not have to create a new project instance. Finally, it checks to see if the get_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 the project and the user 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.

  4. 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 a ValidationError 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.

  5. 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:

    Git Bash command line interface showing the some test cases have passed

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

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:

  1. 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. The APIClient is a class that DRF provides to make testing easier.

    The second fixture is called project_payload. It takes the user fixture as a parameter and returns sample data that you can use for testing operations such as POST requests.

  2. 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 and project_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, the ProjectListCreateView 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)
Enter fullscreen mode Exit fullscreen mode

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

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

If you type the pytest command in your CLI, your tests will run and you should see something similar to this:

Git Bash command line interface showing the some test cases have passed

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]

    ...
Enter fullscreen mode Exit fullscreen mode

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

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

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

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:

Top comments (0)