How to write parameterized unit tests for Django Rest Framework API with pytest?
IT
Rafał Jusiak|Python Developer
Developers who had a chance to test the REST API functionality may have noticed that most of these tests are very similar and there are only slight differences between them. If you understand the basics of testing but want to write better code, then parameterized tests may make your life a lot easier. In this article, I would like to show you how to write such parameterized tests with the pytest package in five steps.
Why should you choose pytest over the default Django unittest library? There are several reasons why I chose this option:
Pytest allows parametrizing the unit tests. As API will require a lot of similar tests that will differ only in the input data and the expected responses, we’ll have many duplicate functions that will differ only in individual variables. This can be minimized by using parameterized tests which I want to show you further in this article.
Tests written with pytest are much simpler. Developers don’t have to define classes, they can just write functions.
Pytest offers more flexibility. It has a powerful plugin-based ecosystem, so if you want to e.g. run tests in parallel to speed them up, you just need to add the pytest-xdist
that’ll handle it automatically.
It can use the so-called fixtures. These are very helpful in creating new tests and reducing duplicate code. I discuss them further below.
Step 1: Install pytest
To use pytest instead of the default Django test solution, you just need to install pytest
and pytest-django
as project dependencies. Running the tests is done with the command pytest .
or pytest path/to/tests.py
if we want to run a single test file.
Example API implementation
Let’s start with a simple example of a REST API written with Django and Django Rest Framework. I defined a Message
model and then created a simple ViewSet
and Serializer
for it. Suppose our requirements were:
- the message has its author,
- it may (but does not have to) have a title,
- it must have body content,
- has a timestamp of creation assigned by default.
API is not too complex: we only provide the ability to get a list of items or a single item, or to create a new Message
entry in the database.
# messaging/models.py
from django.contrib.auth import get_user_model
from django.db import models
from django.utils import timezone
User = get_user_model()
class Message(models.Model):
author = models.ForeignKey(User, related_name="messages", on_delete=models.CASCADE)
title = models.CharField(max_length=64, null=True, blank=True)
body = models.TextField()
timestamp = models.DateTimeField(default=timezone.now)
# messaging/api.py
from rest_framework import serializers
from rest_framework.viewsets import ModelViewSet
from messaging.models import Message
class MessageSerializer(serializers.ModelSerializer):
class Meta:
model = Message
fields = "__all__"
class MessageViewSet(ModelViewSet):
serializer_class = MessageSerializer
queryset = Message.objects.all()
http_method_names = ["get", "post"]
Finally, to arrive at a complete example of the implementation, here’s a router definition and URL configuration of API.
# messaging/urls.py
from django.urls import path, include, re_path
from rest_framework import routers
from messaging.api import MessageViewSet
router = routers.SimpleRouter()
router.register(
r"messages",
MessageViewSet,
basename="message",
)
urlpatterns = [
re_path(
r"^api",
([re_path(r"^/", include(router.urls))], "api", "api"),
)
]
Step 2: Implement Pytest fixtures
Before you start writing tests you’ll need to handle a few things that are required to properly handle our test cases and to reduce repeatability (as for creating test objects). You have to: create a user, that’ll be a sender of requests and will be the author of new messages, prepare the API client to make test requests, have some messages already saved in the database.
You can handle all of the above with pytest fixtures. Fixtures can be treated as an equivalent of a setUp
and tearDown
methods from original Django unittest classes. Fixtures allow you to pass resources into test functions as input arguments. To define a fixture that’s applicable to all of your written tests, you just need to create a conftest.py
file in the root directory of your project, and define a function decorated with the @pytest.fixture
decorator. For further reference and information, visit this page.
Let’s create a conftest.py
file to start with a test User fixture:
# conftest.py
import pytest
from django.contrib.auth import get_user_model
User = get_user_model()
@pytest.fixture
def test_user(db):
user = User.objects.create_user(username="rafal", email="test@example.com")
return user
db
is a fixture that’s taken from pytest-django
plugin, which is used to ensure the Django database is set up. As with any other pytest fixtures - we don’t need to import it from any external modules, we just need to install it.
pytest-django
provides a few fixtures that may be useful to test API endpoints, like a rf
for a django.test.RequestFactory
, or client
for django.test.Client
, but in our case we would like to use a DRF APIClient
that’s already authenticated by our test_user
.
# conftest.py
import pytest
from rest_framework.test import APIClient
# ...
@pytest.fixture
def api_client(test_user):
client = APIClient()
client.force_authenticate(test_user)
return client
The last thing we need are some test_messages
that will be useful in GET method tests.
# conftest.py
import pytest
from messaging.models import Message
# ...
@pytest.fixture
def test_message_1(db, test_user):
message, _ = Message.objects.get_or_create(
author=test_user,
title="Test title",
body="Lorem ipsum dolor sit amet, consectetur adipiscing elit. "
"Aenean blandit finibus nibh et ultrices.",
)
return message
@pytest.fixture
def test_message_2(db, test_user):
message, _ = Message.objects.get_or_create(
author=test_user,
title="Another test title",
body="Donec luctus nulla magna, sit amet dapibus erat vehicula ut.",
)
return message
As you can see, test_user
fixture is used here for the author
of this Message
.
Step 3: Create basic unit tests
With steps 1 and 2 completed, you’re finally able to write your unit test. As the API is stored in the messaging
app, let’s create a tests
directory inside it, with a test_api.py
file. During an execution of pytest .
command, it will automatically find this file and will collect all test functions to execute.
First, we need to make sure that we’re able to receive a collection of all messages through our API. Let’s define our first test function test_get_list
:
# messaging/tests/test_api.py
def test_get_list(api_client, test_message_1, test_message_2):
pass
For this test, we’ll use three of our already defined fixtures: api_client
, test_mesage_1
and test_message_2
. We use the api_client.get()
method to make a GET request to the specified URL.
Since our API address may change in the future (or we just don’t want to use static addresses), we can use the reverse_lazy
Django utility function and take advantage of the fact that url pattern names for the viewset actions are known.
In our case, it will mean that name of our action is "api:message-list"
(api
is the name of the namespace defined in urls.py
, message
is taken from the model name and list
is the name of the operation). By combining this information, we get a simple, dynamic way to retrieve the endpoint URL address.
# messaging/tests/test_api.py
response = api_client.get(reverse_lazy("api:message-list"))
Step 4: Check if the basic unit test went well
Let's make a few assertions to check if the test was meaningful. We’ll check whether the proper list is returned and see whether the two of our database objects are on it, and if those objects have valid ids.
In order to actually verify the correctness of the received list, we can check whether all the necessary fields are returned correctly - this can be done by extending the above test with additional assertions, or by comparing two dictionaries. This is obviously a very simplified version of the test.
# messaging/tests/test_api.py
from django.urls import reverse_lazy
from rest_framework import status
def test_get_list(api_client, test_message_1, test_message_2):
response = api_client.get(reverse_lazy("api:message-list"))
assert response.status_code == status.HTTP_200_OK
data = response.data
assert isinstance(data, list)
assert len(data) == 2
assert data[0]["id"] == test_message_1.id
assert data[1]["id"] == test_message_2.id
Analogically, we can create a test for detail. This time to get the valid URL we'll use `"api:message-detail"`` route.
# messaging/tests/test_api.py
from django.urls import reverse_lazy
from rest_framework import status
def test_get_detail(api_client, test_message_1):
response = api_client.get(
reverse_lazy("api:message-detail", args=(test_message_1.id,))
)
assert response.status_code == status.HTTP_200_OK
data = response.data
assert isinstance(data, dict)
assert data["id"] == test_message_1.id
Step 5: Parameterize your test
In the case of POST requests, not only do we want to make sure that we are able to create the correct Message
object, but we also want to make sure that we cannot create an incorrect object - for example, a message without an author.
There are many different scenarios, and each of them would involve writing a separate function for the test - as in the case of GET list and GET detail. Yes, you could just copy functions and change some variables for each scenario, but after a dozen tests your files would be in a large mess (a great violation of the DRY rule). Besides, you’d also get a lot of contributions to the repository.
So, let's try to write the functions properly using the pytest parameterize
decorator. Quoting from the documentation: "pytest.mark.parametrize
decorator enables parameterization of arguments for a test function". And... that's basically all there is to say about it in theory. Let's see how it works in practice with an example.
For starters, let's take two cases - one when the data is correct and the other when the author is missing from the data. In the first case, the object should be created, so the response status should be 201 (created), and in the second case, our request is invalid, so the status should be 400 (bad request).
# messaging/tests/test_api.py
from django.urls import reverse_lazy
from rest_framework import status
def test_post_ok(api_client):
request_data = {
"author": 1,
"title": "title",
"body": "body"
}
response = api_client.post(reverse_lazy("api:message-list"), data=request_data)
assert response.status_code == status.HTTP_201_CREATED
response_data = response.data
for key, value in request_data.items():
assert response_data[key] == value
def test_post_missing_author(api_client):
request_data = {
"title": "title",
"body": "body"
}
response = api_client.post(reverse_lazy("api:message-list"), data=request_data)
assert response.status_code == status.HTTP_400_BAD_REQUEST
Note how these tests differ, aside from the fact that one of them assumes a correct action and the other an incorrect one. There are different:
- data in the request,
- response statuses,
- checking the data in response.
It turns out that these two tests can be written as one without duplicating the code. Let's use two variable parameters: request_data
and expected_response_status
. These allow you to make one of two tests by replacing the values with specific parameters and making part of the test dependent on the fact that the status code means success.
# messaging/tests/test_api.py
from django.urls import reverse_lazy
from rest_framework import status
def test_post(api_client, request_data, expected_status_code):
response = api_client.post(reverse_lazy("api:message-list"), data=request_data)
assert response.status_code == expected_status_code
if status.is_success(response.status_code):
response_data = response.data
for key, value in request_data.items():
assert response_data[key] == value
At this point, you have a simple function that can validate the endpoint. Next, let’s put some data in with the help of pytest.
Regarding the use of the @pytest.mark.parametrize
decorator: as the first argument to the decorator, you need to pass a string with the argument names separated by commas (in this case it is "request_data, expected_status_code"
).
The second argument of the decorator is a list of parameter tuples (which are of the type pytest.param
). Those tuples consist of values we want to pass to the test function - so in our case they are a dictionary with message data (e.g. {"title": "title", "body": "body"}
) and status code, e.g. status.HTTP_201_CREATED
.
# messaging/tests/test_api.py
import pytest
from django.urls import reverse_lazy
from rest_framework import status
@pytest.mark.parametrize(
"request_data,expected_status_code",
[
pytest.param(
{"author": 1, "title": "title", "body": "body"},
status.HTTP_201_CREATED,
id="complete-data",
),
pytest.param(
{"title": "title", "body": "body"},
status.HTTP_400_BAD_REQUEST,
id="missing-author",
),
],
)
def test_post(api_client, request_data, expected_status_code):
response = api_client.post(reverse_lazy("api:message-list"), data=request_data)
assert response.status_code == expected_status_code
if status.is_success(response.status_code):
response_data = response.data
for key, value in request_data.items():
assert response_data[key] == value
Each pytest.param
corresponds to a separate test. To give them a custom and friendly name, we can additionally pass the keyword id
argument, which will serve as an additional description of our test. When we run the tests with the command pytest messaging/tests/test_api.py
you will see two new tests:
messaging/tests/test_api.py::test_post[complete-data] PASSED
messaging/tests/test_api.py::test_post[missing-author] PASSED
From now on, if we want to add more unit tests to our POST endpoint, we just need to add new parameters, for example:
# messaging/tests/test_api.py
import pytest
from django.urls import reverse_lazy
from rest_framework import status
@pytest.mark.parametrize(
"request_data,expected_status_code",
[
pytest.param(
{"author": 1, "title": "title", "body": "body"},
status.HTTP_201_CREATED,
id="complete-data",
),
pytest.param(
{"title": "title", "body": "body"},
status.HTTP_400_BAD_REQUEST,
id="missing-author",
),
pytest.param(
{"author": 1, "body": "body"},
status.HTTP_201_CREATED,
id="missing-title",
),
pytest.param(
{"author": 1, "title": "title"},
status.HTTP_400_BAD_REQUEST,
id="missing-body",
),
],
)
def test_post(api_client, request_data, expected_status_code):
response = api_client.post(reverse_lazy("api:message-list"), data=request_data)
assert response.status_code == expected_status_code
if status.is_success(response.status_code):
response_data = response.data
for key, value in request_data.items():
assert response_data[key] == value
Writing Parameterized Unit Tests for Django Rest Framework API with Pytest
As you can see, writing parameterized tests using pytest isn’t that complicated. Such tests are very efficient and make application maintenance and supervision a lot easier. As soon as new cases worth constant verification emerge, you can simply add new test parameters and enjoy the simplicity of this process.
Rafał Jusiak
Python Developer
I'm a Senior Python Developer. I've been mastering Python for more than 5 years, and I'm a backend specialist in the Django framework. When I'm not coding, I'm spending time outside: sightseeing, and driving the car. I like to watch classic movies, discover new music, and play board games with my friends.