How to write tests in Django
By datnq, at: 18:43 Ngày 31 tháng 1 năm 2024
How to write tests in Django
For most developers when starting out, writing tests might seem like a tedious task. However, in companies or universities, seasoned programmers, or instructors highly value writing tests and consider it a core part of application development. Understanding and mastering the art of writing tests helps improve performance, enhance application security, and elevate one's skillset. Specifically, today we are going to explore the Django unit tests.
1. Writing and Running Tests
Writing Tests
Django utilizes the standard unittest library for testing. Writing tests is straightforward. First, i create new simple model Animal.
from django.db import models class Animal(models.Model): name = models.CharField() sound = models.CharField() def speak(self): return f'The {self.name} says "{self.sound}"'
Here, I'm creating a class named SimpleTest
that inherits from Django's TestCase
.
from django.test import TestCase
from myapp.models import Animal
class SimpleTestCase(TestCase):
def setUp(self):
Animal.objects.create(name="lion", sound="roar")
Animal.objects.create(name="cat", sound="meow")
def test_animals_can_speak(self):
"""Animals that can speak are correctly identified"""
lion = Animal.objects.get(name="lion")
cat = Animal.objects.get(name="cat")
self.assertEqual(lion.speak(), 'The lion says "roar"')
self.assertEqual(cat.speak(), 'The cat says "meow"')
The above test case consists of two main parts: setUp
and test methods. setUp
helps initialize common values for the test suite, and test methods use those common values to check the functionality of the application.
Running Tests
Once the tests are written, let's run the above test using the following command:
python manage.py test
When dealing with a large number of test cases, let's say an application with 2000-3000 test cases, and you only want to test a specific case or a specific set of tests, how would you do it? Fortunately, Django makes it easy.
# Run all the tests in the animals.tests module
$ python manage.py test animals.tests
# Run all the tests found within the 'animals' package
$ python manage.py test animals
# Run just one test case class
$ python manage.py test animals.tests.SimpleTestCase
# Run just one test method
$ python manage.py test animals.tests.SimpleTestCase.test_animals_can_speak
Output
After using the command to run tests, the application will create a mock database similar to the current database in use. All operations related to adding, editing, or deleting models will be performed on this mock database. We simply need to wait and see the results displayed in the command after running a series of test cases.
Found 3 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...
----------------------------------------------------------------------
Ran 3 tests in 0.073s
OK
Destroying test database for alias 'default'...
The above scenario is where all the tests have passed successfully. In case of failure in any test case, below is a part of the error message.
self.assertEqual(response.status_code, 201)
AssertionError: 200 != 201
----------------------------------------------------------------------
Ran 3 tests in 0.078s
FAILED (failures=1)
Destroying test database for alias 'default'...
2. Some Useful Libraries for Testing
Mock
Mock testing is a method in software testing where mock objects are used to replace real objects in the system. The goal of mock testing is to isolate a part of the system and test it independently of other components. Below is a simple example of using mock:
class MessageAPITestCase(APITestCase):
@patch("api.views.MessageViewSet.list")
def test_api_list_message(self, mock_list):
mock_list.return_value = Response(
{"field1": "mocked_value", "field2": 99}
)
response = self.client.get("/api/messages/")
mock_list.assert_called_once()
self.assertEqual(response.status_code, 201)
self.assertEqual(
response.data, {"field1": "mocked_value", "field2": 99}
)
This code illustrates using mock to simulate an API. Instead of calling a third-party API and running the test, we assume that the API returns JSON values as expected. We then use that data to continue running the test. The diagram below helps understand mock testing better:
Factory Boy
As we know, initializing tests is always necessary. For small applications, initialization can be simple. However, when models have many relationships and are heavily interdependent, Factory Boy is a powerful tool to assist in initializing complex scenarios.
To initialize a model with Factory Boy, create a factory for that model:
import factory
from app.models import Message
from .user import UserFactory
from .room import RoomFactory
class MessageFactory(factory.django.DjangoModelFactory):
class Meta:
model = Message
sender = factory.SubFactory(UserFactory)
recipient = factory.SubFactory(UserFactory)
room = factory.SubFactory(RoomFactory)
content = "This is a message"
In the setUp
method, instead of initializing using a queryset, we can create it like this:
self.room = RoomFactory.create()
Here, everyone is probably wondering, so which part of this code is fast? The model below will describe that:
BOOM! This makes it easy to create a model during testing without having to initialize a series of foreign keys.
3. URL Testing
In the modern era, URL testing is an essential part of web application development. Faced with various scenarios, from simple to complex, URL testing ensures that routes and views in your application work as expected.
from django.test import TestCase
from django.urls import reverse_lazy
class HomeTestCase(TestCase):
def setUp(self):
self.user1 = UserFactory.create()
def test_get_success(self):
url = reverse_lazy('my-view-name')
response = self.client.get(url)
self.assertEqual(200, response.status_code)
The above code is a simple example of URL Testing. Disregarding the simple setUp
, focus on test_get_success
. The URL is obtained from a hardcoded name through Django's reverse_lazy
method, and the response includes everything the view returns.
4. Speeding Up Test Execution
a. Failfast
When running a test suite, you may not want to wait until all tests are completed to know the results. Failfast allows stopping the test run as soon as a test fails.
Pros: Stopping the test run upon encountering an error helps quickly identify and fix the issue without waiting for the entire test suite to complete. It also minimizes costs as you don't have to spend time and money when the application has a bug. Instead, you can focus on fixing the issue.
Cons: Sometimes failfast is not as omnipotent as we might think. For a programmer who wants to test all cases to gather statistics and understand where the errors are concentrated, failfast should not be used.
b. Keepdb
Usually, creating and dropping a database during testing can be time-consuming. Keepdb preserves the database after running tests, reducing the time needed to recreate the database for each test suite.
Pros: Speeds up continuous test execution: Keeping the database intact avoids the need to recreate data every time a test is run.
Cons: Data mixing risk: If a test changes data in the database, there is a risk of encountering issues with keeping the database.
c. Parallel
Dividing the test suite into parts and running them in parallel can make the best use of server resources and reduce the overall time for the entire test suite.
Pros: Reduces test execution time: Running tests in parallel makes efficient use of multiple CPUs.
Cons: Complicates data management: Ensuring that tests do not interact with the same data to avoid conflicts becomes necessary. Additionally, running tests in parallel shares initialization data between test cases, making it error-prone.
5. Directory Structure When Writing Tests
Let’s say we’ve just created a new Django app. The first thing we do is delete the default but useless tests.py module that python manage.py startapp creates. In its place, we create a directory called tests and place an empty init.py within. Inside that new directory, because most apps need them, we create test_forms.py, test_models.py, test_views.py modules. Tests that apply to forms go into test_forms.py, model tests go into test_models.py, and so on. Here’s what it looks like:
popsicles/
__init__.py
admin.py
forms.py
models.py
tests/
__init__.py
test_forms.py
test_models.py
test_views.py
views.py
Note that it is not mandatory for developers to write exactly as above, but it is advisable to do so. Adhering to common conventions makes the code easier to maintain and develop.