In software engineering, testing is a process to check whether the actual software product performs as expected and is bug free.
There are a lot of ways to test software through both manual and automated tests. But in this project, we’ll focus more on automated testing. However, we’ll first dive into the different ways of testing software, including their pros and their cons, and also talk about the concept of the testing pyramid. We’ll also check the tools needed to add tests to a Django application and add tests to the models and the viewsets. This chapter will help you understand testing for developers and also how to write tests for a Django API.
In this chapter, we’ll be covering the following topics:
You can find the code of the current chapter at this link: https://github.com/PacktPublishing/Full-stack-Django-and-React/tree/chap5.
To make it simple, testing is finding out how well something works.
However, the process comprises a group of techniques to determine the correctness of the application under a script or manual test directly on the user interface. The aim is to detect failures, including bugs and performance issues, in the application, so that they can be corrected.
Most of the time, testing is done by comparing the software requirements to the actual software product. If one of the requirements is to make sure that input only accepts numbers and not characters or files, a test will be conducted to check whether the input has a validation system to reject non-number values in the input.
However, testing also involves an examination of code and the execution of code in various environments and conditions.
Software testing is the process of examining the behavior of the software under test for validation or verification. It considers the attributes of reliability, scalability, reusability, and usability to evaluate the execution of the software components (servers, database, application, and so on) and find software bugs, errors, or defects.
Software testing has a lot of benefits, some of which are as follows:
Testing your software is important because it helps reduce the impact of bugs through bug identification and resolution. Some bugs can be quite dangerous and can lead to financial losses or endanger human life. Here are some historical examples:
Source: https://lexingtontechnologies.ng/software-testing/.
Testing is typically classified into three categories:
However, these tests can also be classified into two different types:
First, let’s see what manual testing is.
Manual testing is the process of testing software manually to find defects or bugs. It’s the process of testing the functionalities of an application without the help of automation tools.
An example of manual testing is when test users are called to test an application or a special feature. They can be asked to test a specific form, push the application to its limits when it comes to performance, and much more.
Manual testing has a lot of advantages:
However, manual testing also has some cons:
Even if manual testing sounds very appealing, it can be quite a time- and resource-consuming exercise, and developers definitely do not make really good manual testers. Let’s see how automated testing can erase the cons of manual testing and place better development at the center of testing.
Automated testing is simply the process of testing software using automation tools to find defects. These automation tools can be scripts written in the language used to build the application or some software or drivers (such as Selenium, WinRunner, and LoadRunner) to make automated testing easier and faster.
Automated testing fixes the cons of manual testing, and it also has more advantages, as shown in the following list:
However, automated testing is also inconvenient in some ways:
The real value of automated testing and manual testing comes when each is used in the right environment.
For example, manual testing is much more useful on frontend projects where you want to test the usability and user experience. Automated testing can be useful to test methods or functions in the code and is very useful for finding bugs or security issues.
In this chapter, we’ll focus on writing automated tests in Python. As we are developing an API, we want to make sure that the system is reliable and behaves as we want it to, but it should also be secure against the possible issues of the next added feature.
This said, let’s talk about testing in Django and introduce the notion of test-driven development (TDD).
Testing in Python, particularly in Django, is very simple and easy. The framework actually provides many tools and utilities you can use to write tests for the models, serializers, or views in the application.
However, the Python ecosystem for testing relies a lot on one tool to write tests, and this tool has deep integration with Django. The tool is named Pytest (https://docs.pytest.org) and is a framework for writing small and readable tests. Used with Django, Pytest is mainly used for API testing by writing code to test API endpoints, databases, and user interfaces.
But why use Pytest? Well, it has the following advantages:
We’ll use Pytest in this project to write two kinds of tests: integration tests and unit tests.
Before starting to code, let’s learn about integration testing and unit testing by considering the concepts of TDD and the testing pyramid.
The testing pyramid is a framework that can help developers start with testing to create high-quality software. Basically, the testing pyramid specifies the types of tests that should be included in an automated test suite.
First of all, remember that the testing pyramid operates at three levels:
The following figure shows the positions of each of these levels in the pyramid and how they are prioritized in terms of the speed performance and level of integration or isolation:
Figure 5.1 – The testing pyramid
In the preceding figure, the base level is occupied by unit testing. Unit tests target individual components or functionality to check whether they work as expected in isolated conditions. In our backend project, an example would be to test whether the like_post method on the User class model actually performs as intended. We are not testing the whole User model; we are testing one method of the User model class.
It’s definitely a good habit to write a lot of unit tests. They should comprise at least 60% of all the tests in your code base because they are fast, short, and test a lot of components.
On the second level, you have integration tests. If unit tests verify small pieces of a code base, integration tests test how this code interacts with other code or other parts of the software. A useful, albeit controversial, example of integration testing is writing a test for a viewset. When testing a viewset, you are also testing the permissions, the authentication classes, the serializers, the models, and the database if possible. It’s a test of how the different parts of the Django API work together.
An integration test can also be a test between your application and an external service, a payment API, for example.
On the third level at the top of the pyramid, you have end-to-end tests. These kinds of tests ensure that the software is working as required. They test how the application works from beginning to end.
In this book, we’ll focus on unit and integration testing. Note that integration tests are the subject of some misunderstandings that will be cleared up once we define them. According to my personal experience, unit tests in Django are written more on the model and serializer side of each application. They can be used for testing the creation of an object in the database as well as for retrieving, updating, or deletion.
Regarding viewset tests, I believe that they can act as integration tests because running them calls on permissions, authentication, serializers, validation, and also models, depending on the action you are performing.
Returning to unit tests, they are more effective when using TDD, which comprises software development practices that focus on writing unit test cases before developing the feature. Even if it sounds counter-intuitive, TDD has a lot of advantages:
However, we didn’t particularly respect the TDD rule in the book. We relied on the Django shell and a client to test the feature of the REST API we are building. For the next features that will be added to the project, tests will be written before coding the feature.
With concepts such as TDD, unit and integration testing, and testing pyramid understood, we can now configure the testing environment.
Pytest, taken alone, is simply a Python framework to write unit tests in Python programs. Thankfully, there is a plugin for Pytest to write tests in Django projects and applications.
Let’s install and configure the environment for testing by using the following command:
pip install pytest-django
Once the package is installed, create a new file called pytest.ini at the root of the Django project:
pytest.ini
[pytest] DJANGO_SETTINGS_MODULE=CoreRoot.settings python_files=tests.py test_*.py *_tests.py
Once it’s done, run the pytest command:
pytest
You’ll see the following output:
======================== test session starts ============================ platform linux -- Python 3.10.2, pytest-7.0.1, pluggy-1.0.0 django: settings: CoreRoot.settings (from ini) rootdir: /home/koladev/PycharmProjects/Full-stack-Django-and-React, configfile: pytest.ini plugins: django-4.5.2 collected 0 items
Great! Pytest is installed in the project, and we can write the first test in the project to test the configuration.
The Pytest environment is configured, so let’s see how we can write a simple test using Pytest.
At the root of the project, create a file called tests.py. We’ll simply write a test to test the sum of a function.
Following the TDD concept, we’ll write the test first and make it fail:
tests.py
def test_sum(): assert add(1, 2) == 3
This function is written to check for a condition, justifying the usage of the assert Python keyword. If the condition after the assert is true, the script will continue or stop the execution. If that’s not the case, an assertion error will be raised.
If you run the pytest command, you’ll receive the following output:
Figure 5.2 – Failing tests
From the preceding output, we are sure that the test has failed. Let’s now write the feature to pass the test.
In the same file, tests.py, add the following function:
tests.py
def add(a, b): return a + b def test_sum(): assert sum(1, 2) == 3
Now, run the pytest command again in the terminal. Everything should now be green:
Figure 5.3 – Test passes successfully
Great! You have written the first test in the project using Pytest. In the next section, we’ll be writing tests for the models of the project.
When applying testing to a Django project, it’s always a good idea to start with writing tests for the models. But why test the models?
Well, it gives you better confidence in your code and the connections to the database. It’ll make sure that methods or attributes on the model are well represented in the database, but it can also help you with better code structure, resolving bugs, and building documentation.
Without further ado, let’s start by writing tests for the User model.
Inside the core/user directory, create a new file called tests.py. We’ll write tests to create a user and a simple user:
core/user/tests.py
import pytest from core.user.models import User data_user = { "username": "test_user", "email": "[email protected]", "first_name": "Test", "last_name": "User", "password": "test_password" }
Once the imports and the data to create the user have been added, we can write the test function:
core/user/tests.py
@pytest.mark.django_db def test_create_user(): user = User.objects.create_user(**data_user) assert user.username == data_user["username"] assert user.email == data_user["email"] assert user.first_name == data_user["first_name"] assert user.last_name == data_user["last_name"]
Above the test_create_user function, you’ll probably notice some syntax. It’s called a decorator, and it’s basically a function that takes another function as its argument and returns another function.
@pytest.mark.django_db gives us access to the Django database. Try to remove this decorator and run the tests.
You’ll get an error output with a similar message at the end:
=================================================================== short test summary info =================================================================== FAILED core/user/tests.py::test_create_user - RuntimeError: Database access not allowed, use the "django_db" mark, or the "db" or "transactional_db" fixture...
Well, re-add the decorator and run the pytest command and all tests should pass normally.
Let’s do another test to make sure that the creation of superuser works perfectly.
Add a new dictionary containing the data needed to create superuser:
core/user/tests.py
data_superuser = { "username": "test_superuser", "email": "[email protected]", "first_name": "Test", "last_name": "Superuser", "password": "test_password" }
And here’s the function that tests the creation of superuser:
core/user/tests.py
@pytest.mark.django_db def test_create_superuser(): user = User.objects.create_superuser(**data_superuser) assert user.username == data_superuser["username"] assert user.email == data_superuser["email"] assert user.first_name == data_superuser["first_name"] assert user.last_name == data_superuser["last_name"] assert user.is_superuser == True assert user.is_staff == True
Run the tests again, and everything should be green.
Great! Now that we have a better understanding of how pytest works for tests, let’s write tests for the Post model.
To create a model, we need to have a user object ready. This will also be the same for the Comment model. To avoid repetition, we’ll simply write fixtures.
A fixture is a function that will run before each test function to which it’s applied. In this case, the fixture will be used to feed some data to the tests.
To add fixtures in the project, create a new Python package called fixtures in the core directory.
In the core/fixtures directory, create a file called user.py. This file will contain a user fixture:
core/fixtures/user.py
import pytest from core.user.models import User data_user = { "username": "test_user", "email": "[email protected]", "first_name": "Test", "last_name": "User", "password": "test_password" } @pytest.fixture def user(db) -> User: return User.objects.create_user(**data_user)
In the preceding code, the @pytest.fixture decorator labels the function as a fixture. We can now import the user function in any test and pass it as an argument to the test function.
Inside the core/post directory, create a new file called tests.py. This file will then test for the creation of a post.
Here’s the code:
core/post/tests.py
import pytest from core.fixtures.user import user from core.post.models import Post @pytest.mark.django_db def test_create_post(user): post = Post.objects.create(author=user, body="Test Post Body") assert post.body == "Test Post Body" assert post.author == user
As you can see, we are importing the user function from user.py in the fixtures directory and passing it as an argument to the test_create_post test function.
Run the pytest command, and everything should be green.
Now that we have a working test for the Post model, let’s write tests for the Comment model.
Writing tests for the Comment model requires the same steps as the tests for the Post model. First of all, create a new file called post.py in the core/fixtures directory.
This file will contain the fixture of a post, as it’s needed to create a comment.
But the post fixture will also need a user fixture. Thankfully, it’s possible with Pytest to inject fixtures into other fixtures.
Here’s the code for the post fixture:
core/fixtures/post.py
import pytest from core.fixtures.user import user from core.post.models import Post @pytest.fixture def post(db, user): return Post.objects.create(author=user, body="Test Post Body")
Great! With the fixtures added, we can now write the test for comment creation.
Inside the core/comment/ directory, create a new file called tests.py:
core/comment/tests.py
import pytest from core.fixtures.user import user from core.fixtures.post import post from core.comment.models import Comment @pytest.mark.django_db def test_create_comment(user, post): comment = Comment.objects.create(author=user, post=post, body="Test Comment Body") assert comment.author == user assert comment.post == post assert comment.body == "Test Comment Body"
Run the tests with the pytest command, and everything should be green.
Great! We’ve just written tests for all the models in the project. Let’s move on to writing tests for the viewsets.
Viewsets or endpoints are the interfaces of the business logic that the external clients will use to fetch data and create, modify, or delete data. It’s always a great habit to have tests to make sure that the whole system, starting from a request to the database, is working as intended.
Before starting to write the tests, let’s configure the Pytest environment to use the API client from DRF.
The API client is a class that handles different HTTP methods, as well as features such as authentication in testing, which can be very helpful for directly authenticating without a username and password to test some endpoints. Pytest provides a way to add configurations in a testing environment.
Create a file named conftest.py at the root of the project. Inside the file, we’ll create a fixture function for our custom client:
conftest.py
import pytest from rest_framework.test import APIClient @pytest.fixture def client(): return APIClient()
Great! We can now directly call this client in the next tests.
Let’s start by testing the authentication endpoints.
Inside the core/auth directory, create a file named tests.py. Instead of writing test functions directly, we write a class that will contain the testing methods as follows:
core/auth/tests.py
import pytest from rest_framework import status from core.fixtures.user import user class TestAuthenticationViewSet: endpoint = '/api/auth/'
Let’s add the test_login method to the TestAuthenticationViewSet class:
Core/auth/tests.py
... def test_login(self, client, user): data = { "username": user.username, "password": "test_password" } response = client.post(self.endpoint + "login/", data) assert response.status_code == status.HTTP_200_OK assert response.data['access'] assert response.data['user']['id'] == user.public_id.hex assert response.data['user']['username'] == user.username assert response.data['user']['email'] == user.email ...
This method basically tests the login endpoint. We are using the client fixture initialized in the conftest.py file to make a post request. Then, we test for the value of status_code of the response and the response returned.
Run the pytest command, and everything should be green.
Let’s add tests for the register and refresh endpoints:
core/auth/tests.py
... @pytest.mark.django_db def test_register(self, client): data = { "username": "johndoe", "email": "[email protected]", "password": "test_password", "first_name": "John", "last_name": "Doe" } response = client.post(self.endpoint + "register/", data) assert response.status_code == status.HTTP_201_CREATED def test_refresh(self, client, user): data = { "username": user.username, "password": "test_password" } response = client.post(self.endpoint + "login/", data) assert response.status_code == status.HTTP_200_OK data_refresh = { "refresh": response.data['refresh'] } response = client.post(self.endpoint + "refresh/", data_refresh) assert response.status_code == status.HTTP_200_OK assert response.data['access']
In the preceding code, within the test_refresh method, we log in to get a refresh token to make a request to get a new access token.
Run the pytest command again to run the tests, and everything should be green.
Let’s move on to writing tests for PostViewSet.
Before starting to write the viewsets tests, let’s quickly refactor the code to simply write the tests and follow the DRY rule. Inside the core/post directory, create a Python package called tests. Once it’s done, rename the tests.py file in the core/post directory to test_models.py and move it to the core/post/tests/ directory.
Inside the same directory, create a new file called test_viewsets.py. This file will contain tests for PostViewSet:
core/post/tests/test_viewsets.py
from rest_framework import status from core.fixtures.user import user from core.fixtures.post import post class TestPostViewSet: endpoint = '/api/post/'
PostViewSet handles requests for two types of users:
Each type of user has different permissions on the post resource. So, let’s make sure that these cases are handled:
core/post/tests/test_viewsets.py
... def test_list(self, client, user, post): client.force_authenticate(user=user) response = client.get(self.endpoint) assert response.status_code == status.HTTP_200_OK assert response.data["count"] == 1 def test_retrieve(self, client, user, post): client.force_authenticate(user=user) response = client.get(self.endpoint + str(post.public_id) + "/") assert response.status_code == status.HTTP_200_OK assert response.data['id'] == post.public_id.hex assert response.data['body'] == post.body assert response.data['author']['id'] == post.author.public_id.hex
For these tests, we are forcing authentication. We want to make sure that authenticated users have access to the post’s resources. Let’s now write a test method for post creation, updating, and deletion:
core/post/tests/test_viewsets.py
... def test_create(self, client, user): client.force_authenticate(user=user) data = { "body": "Test Post Body", "author": user.public_id.hex } response = client.post(self.endpoint, data) assert response.status_code == status.HTTP_201_CREATED assert response.data['body'] == data['body'] assert response.data['author']['id'] == user.public_id.hex def test_update(self, client, user, post): client.force_authenticate(user=user) data = { "body": "Test Post Body", "author": user.public_id.hex } response = client.put(self.endpoint + str(post.public_id) + "/", data) assert response.status_code == status.HTTP_200_OK assert response.data['body'] == data['body'] def test_delete(self, client, user, post): client.force_authenticate(user=user) response = client.delete(self.endpoint + str(post.public_id) + "/") assert response.status_code == status.HTTP_204_NO_CONTENT
Run the tests, and the outcomes should be green. Now, for the anonymous users, we want them to access the resource in reading mode, so they can’t create, modify, or delete a resource. Let’s test and validate these features:
core/post/tests/test_viewsets.py
... def test_list_anonymous(self, client, post): response = client.get(self.endpoint) assert response.status_code == status.HTTP_200_OK assert response.data["count"] == 1 def test_retrieve_anonymous(self, client, post): response = client.get(self.endpoint + str(post.public_id) + "/") assert response.status_code == status.HTTP_200_OK assert response.data['id'] == post.public_id.hex assert response.data['body'] == post.body assert response.data['author']['id'] == post.author.public_id.hex
Run the tests to make sure everything is green. After that, let’s test the forbidden methods:
core/post/tests/test_viewsets.py
... def test_create_anonymous(self, client): data = { "body": "Test Post Body", "author": "test_user" } response = client.post(self.endpoint, data) assert response.status_code == status.HTTP_401_UNAUTHORIZED def test_update_anonymous(self, client, post): data = { "body": "Test Post Body", "author": "test_user" } response = client.put(self.endpoint + str(post.public_id) + "/", data) assert response.status_code == status.HTTP_401_UNAUTHORIZED def test_delete_anonymous(self, client, post): response = client.delete(self.endpoint + str(post.public_id) + "/") assert response.status_code == status.HTTP_401_UNAUTHORIZED
Run the tests again. Great! We’ve just written tests for the post viewset. You should now have a better understanding of testing with viewsets.
Let’s quickly write tests for CommentViewSet.
Before starting to write the viewset tests, let’s also quickly refactor the code for writing the tests. Inside the core/comment directory, create a Python package called tests. Once it’s done, rename the tests.py file in the core/post directory to test_models.py and move it to the core/comment/tests/ directory.
Inside the same directory, create a new file called test_viewsets.py. This file will contain tests for CommentViewSet.
Just like in PostViewSet, we have two types of users, and we want to write test cases for each of their permissions.
However, before creating comments, we need to add comment fixtures. Inside the core/fixtures directory, create a new file called comment.py and add the following content:
core/fixtures/comment.py
import pytest from core.fixtures.user import user from core.fixtures.post import post from core.comment.models import Comment @pytest.fixture def comment(db, user, post): return Comment.objects.create(author=user, post=post, body="Test Comment Body")
After that, inside core/comment/tests/test_viewsets.py, add the following content first:
core/comment/tests/test_viewsets.py
from rest_framework import status from core.fixtures.user import user from core.fixtures.post import post from core.fixtures.comment import comment class TestCommentViewSet: # The comment resource is nested under the post resource endpoint = '/api/post/'
Next, let’s add tests to the list and retrieve comments as authenticated users:
core/comment/tests/test_viewsets.py
... def test_list(self, client, user, post, comment): client.force_authenticate(user=user) response = client.get(self.endpoint + str(post.public_id) + "/comment/") assert response.status_code == status.HTTP_200_OK assert response.data["count"] == 1 def test_retrieve(self, client, user, post, comment): client.force_authenticate(user=user) response = client.get(self.endpoint + str(post.public_id) + "/comment/" + str(comment.public_id) + "/") assert response.status_code == status.HTTP_200_OK assert response.data['id'] == comment.public_id.hex assert response.data['body'] == comment.body assert response.data['author']['id'] == comment.author.public_id.hex
Make sure that these tests pass by running the pytest command. The next step is to add tests for comment creation, updating, and deletion:
core/comment/tests/test_viewsets.py
... def test_create(self, client, user, post): client.force_authenticate(user=user) data = { "body": "Test Comment Body", "author": user.public_id.hex, "post": post.public_id.hex } response = client.post(self.endpoint + str(post.public_id) + "/comment/", data) assert response.status_code == status.HTTP_201_CREATED assert response.data['body'] == data['body'] assert response.data['author']['id'] == user.public_id.hex def test_update(self, client, user, post, comment): client.force_authenticate(user=user) data = { "body": "Test Comment Body Updated", "author": user.public_id.hex, "post": post.public_id.hex } response = client.put(self.endpoint + str(post.public_id) + "/comment/" + str(comment.public_id) + "/", data) assert response.status_code == status.HTTP_200_OK assert response.data['body'] == data['body'] def test_delete(self, client, user, post, comment): client.force_authenticate(user=user) response = client.delete(self.endpoint + str(post.public_id) + "/comment/" + str(comment.public_id) + "/") assert response.status_code == status.HTTP_204_NO_CONTENT
Run the tests again to make sure everything is green. Let’s write tests for the anonymous users now.
First of all, we need to make sure that they can access the resources with the GET method:
core/comment/tests/test_viewsets.py
... def test_list_anonymous(self, client, post, comment): response = client.get(self.endpoint + str(post.public_id) + "/comment/") assert response.status_code == status.HTTP_200_OK assert response.data["count"] == 1 def test_retrieve_anonymous(self, client, post, comment): response = client.get(self.endpoint + str(post.public_id) + "/comment/" + str(comment.public_id) + "/") assert response.status_code == status.HTTP_200_OK
Next, we need to make sure that an anonymous user can’t create, update, or delete a comment:
core/comment/tests/test_viewsets.py
def test_create_anonymous(self, client, post): data = {} response = client.post(self.endpoint + str(post.public_id) + "/comment/", data) assert response.status_code == status.HTTP_401_UNAUTHORIZED def test_update_anonymous(self, client, post, comment): data = {} response = client.put(self.endpoint + str(post.public_id) + "/comment/" + str(comment.public_id) + "/", data) assert response.status_code == status.HTTP_401_UNAUTHORIZED def test_delete_anonymous(self, client, post, comment): response = client.delete(self.endpoint + str(post.public_id) + "/comment/" + str(comment.public_id) + "/") assert response.status_code == status.HTTP_401_UNAUTHORIZED
In the preceding cases, the data dict is empty because we are expecting error statuses.
Run the tests again to make sure that everything is green!
And voilà. We’ve just written tests for CommentViewSet. We also need to write tests for the UserViewSet class, but this will be a small project for you.
In this section, let’s do a quick hands-on exercise. You’ll write the code for the UserViewSet class. It’s quite similar to the other tests we’ve written for PostViewSet and CommentViewSet. I have provided you with the structure of the class, and all you have to do is to write the testing logic in the methods. The following is the structure you need to build on:
core/user/tests/test_viewsets.py
from rest_framework import status from core.fixtures.user import user from core.fixtures.post import post class TestUserViewSet: endpoint = '/api/user/' def test_list(self, client, user): pass def test_retrieve(self, client, user): pass def test_create(self, client, user): pass def test_update(self, client, user): pass
Here are the requirements concerning the tests:
You can find the solution to this exercise here: https://github.com/PacktPublishing/Full-stack-Django-and-React/blob/main/core/user/tests/test_viewsets.py.
In this chapter, we learned about testing, the different types of testing, and their advantages. We also introduced testing in Django using Pytest and wrote tests for the models and viewsets. These skills acquired in writing tests using the TDD method help you better design your code, prevent bugs tied to code architecture, and improve the quality of the software. Not to forget, they also give you a competitive advantage in the job market.
This is the last chapter of Part 1, Technical Background. The next part will be dedicated to React and connecting the frontend to the REST API we’ve just built. In the next chapter, we’ll learn more about frontend development and React, and we’ll also create a React project and run it.