Unit Tests in Django: What you should know
Content Index
- Why test?
- Why Learn Testing in the Age of AI?
- What are tests for?
- What should we try?
- Limitations of Testing
- Understanding the first Tests
- Test Django applications
- Comments Crud
- Index
- Generating tests with AI - Gemini Agent
- Defining the Prompt for Gemini
- Generating tests for views and forms
- Final Recommendations
Why test?
Testing helps ensure that your application will function as expected and as the application grows in modules and complexity, new tests can be implemented and current tests adapted.
It is important to mention that the tests are not perfect, that is, even if the application passes all the tests, it does not mean that the application is error-free, but it is a good initial indicator of the quality of the software. Additionally, testable code is generally a sign of good software architecture.
Testing must be part of the application development cycle to guarantee its proper functioning by running them constantly.
Why Learn Testing in the Age of AI?
Today, with the rise of Artificial Intelligence, implementing tests is easier than ever. AI can handle 80% to 90% of the work involved in writing test code. However, to use these tools correctly, you first need to understand the basics.
I recommend you don't skip this section; we'll cover the essential operations so you understand how to test your components and why it's a time investment that pays for itself.
What are tests for?
Testing involves evaluating individual components to ensure that, when combined, the entire system functions correctly. With tests, we can:
- Simulate HTTP requests: Test form submissions (POST) or page loads (GET).
- Validate the database: Verify that records are created, edited, or deleted as expected.
- Evaluate behavior: Confirm that redirects work, that security validations prevent unauthenticated users, and that errors are handled gracefully.
- Save time in the long run: When adding new features, simply running a command will tell you if you've broken something that was already working (regression testing).
One of Django's greatest advantages is its modularity; everything is a separate component—one for views, another for forms, models, and so on. In testing, we can evaluate the following in isolation:
- View responses: Status codes (200 OK, 404 Not Found, etc.).
- Business logic: Signals, model methods, and utilities.
- Forms: Data validation and field cleaning.
What should we try?
Testing should focus on testing small units of code in isolation or individually.
For example, in a Django application or a web application in general:
- Views
- View responses
- State codes
- Nominal conditions (GET, POST, etc.) for a view function
- Forms
- Individual help functions
You can get more information in the official documentation:
https://docs.djangoproject.com/en/dev/topics/testing/
Limitations of Testing
It's vital to understand that testing isn't infallible. Even if all your tests pass, this isn't an absolute guarantee that the project is 100% error-free. What it means is that the scenarios you designed work well, but there may always be edge cases that weren't covered.
Understanding the first Tests
So that the use of Tests is understood in a less abstract way, we are going to create a small exercise of mathematical operations before starting to create Tests to test modules of our application, such as views.
In Django, we have very easy use of tests, if you notice, already in the applications we have a file called tests.py, which is to create our tests:
comments.tests.py
elements.tests.py
It is also not necessary to install any separate library, since as we indicated before, Django in its "Batteries Included" philosophy, brings everything necessary to create complete applications without the need to install additional dependencies, at least for the basic elements such as the tests that should be part of any development.
To know the basic operation of tests, we are going to create a test, in Django, at the application level, we have a file called tests.py in which we place the tests for the application; for this exercise, you can use any project in Django; in the tests.py file of a Django application, we create some functions that solve the mathematical operations:
<ProyectDjango><AppDjango>\tests.py
def add(a: int , b: int) -> int:
return a + b
def subtract(a: int, b: int) -> int:
return b - a
def multiply(a: int, b: int) -> int:
return a * b
def divide(a: int, b: int) -> int:
return b / aAt the moment, they are not tests intended for application, but rather basic mathematical operations, but with them we will be able to better understand how tests work; now, in the previous file, we define the following code, which correspond to the tests:
<ProyectDjango><AppDjango>\tests.py
***
assert add(2,2) == 4
assert subtract(5,2) == 3
assert multiply(10,10) == 100
assert divide(100,25) == 4As you can see, there are only 4 lines of code in Python, but, using the assert keyword that is used to verify the outputs; specifically, we will be testing that the arithmetic operations are equal to the expected values, that is, if for the addition operation, we add 1+1, the expected result is 2.
Finally, to test the previous tests we run:
$ python manage.py testTo run all the tests, which in this case corresponds to only one file; we will have an output like the following:
Found 0 test(s).
System check identified no issues (0 silenced).
----------------------------------------------------------------------
Ran 0 tests in 0.000s
OKIn which, as you can see, NOTHING was executed:
Found 0 test(s).For Django to recognize tests, they must be inside a class that inherits from TestCase and implemented in methods that begin with test_:
class OperationTest(TestCase):
def test_op(self):
assert add(3,3) == 6
assert add(5,4) == 9
assert subtract(5,2) == 3
assert multiply(10,10) == 100
assert divide(100,1) == 100Now if you run:
Found 1 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.001s
OKAs you can see, all operations were executed satisfactorily, therefore, the application passed the tests; One factor that you must take into account is that, although the application passes the tests, it does not guarantee that it does not have errors, simply that not enough tests were done or defined to test all possible scenarios; even so, it is a good meter to know the status of the application and repair possible problems.
If, for example, we place an invalid output when doing the tests:
def test_add() -> None:
assert add(1, 2) == 4We will see an output like the following:
ERROR: alert.tests (unittest.loader._FailedTest.alert.tests)
----------------------------------------------------------------------
ImportError: Failed to import test module: alert.tests
Traceback (most recent call last):
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/unittest/loader.py", line 407, in _find_test_path
module = self._get_module_from_name(name)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/unittest/loader.py", line 350, in _get_module_from_name
__import__(name)
File "/***/tests.py", line 12, in <module>
assert add(1,2) == 4
^^^^^^^^^^^^^
AssertionError
----------------------------------------------------------------------
Ran 1 test in 0.000sIn which it clearly indicates that an error occurred and that it corresponds to the addition operation:
assert add(1,2) == 4If, on the other hand, the error is at the application level, for example, in the addition function, instead of adding a+b, we add a+a:
def add(a: int , b: int) -> int:
return a + aWe will have as output:
assert 2 == 4But, if the output function were like the following:
def test_add() -> None:
assert add(1, 1) == 2We will see that the test is accepted, although we clearly have problems at the application level; therefore, it is not a sufficient condition that the application passes the tests to indicate that it has no problems, simply not enough tests were created or the tests were not implemented correctly.
Test Django applications
We've implemented a lot of things in Django, from Hello World to list filters; now it's time to implement tests for these developments.
At the moment, we have done some testing methods and there are two factors that we have to keep in mind, if we make requests using Client(), for example:
client = Client()
response = client.get("/")Or instantiating models or other classes in the test:
Comment.objects.create(text='text 1')The connections are made in the context of the application under development according to what we have configured in the test:
import unittest.TestCaseOr in memory:
from django.test import TestCaseThis is easily probable if we do some tests; if from a test, you try to make a connection to the database using the client or an instance of a model:
import unittest
from django.test import Client
from .models import Comment
class ContactTests(unittest.TestCase):
def test_index(self):
print(Comment.objects.all())
client = Client()
response = client.get("/")
print(response)
***And we execute:
$ python manage.py testWe will see that it returns a list with the data that we have in the development database, but, if we configure it with Django's TestCase:
from django.test import Client, TestCase
from .models import Comment
class ContactTests(TestCase):
def test_index(self):
print(Comment.objects.all())
client = Client()
response = client.get("/")
print(response)
***We will see that it returns a result empty of comments, even though we have comments in the development database.
In both cases, to obtain at least the database structure, the one we have configured is used:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}To understand how testing works, let's configure the database for the testing environment:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
'TEST': {
'NAME': BASE_DIR / 'db.sqlite3',
},
}
}Before executing, is recommended to make a copy of the SQLite database (<your-project>\db.sqlite3) so that you can restore it later, since it will be deleted at the end of the test, finally, we execute:
$ python manage.py testRegardless of which TestCase class you are using, since it is the same database that is being used for development, it will ask you if you really want to delete it:
Creating test database for alias 'default'...
Destroying old test database for alias 'default'...
Type 'yes' if you would like to try deleting the test database '/Users/andrescruz/Desktop/proyects/django/curso/django-base/db.sqlite3', or 'no' to cancel: We say “yes”, since we have the copy:
yesAnd we will see that the database is destroyed (if you try to enter the application http://127.0.0.1:8000, you will see that it returns an error that it cannot find the table for the session, which is used internally by Django before resolving the requests implemented by us in the views):
OK
Destroying test database for alias 'default'...As a result of:
print(Comment.objects.all())We will see that, although we have data in the development database, the list appears empty (remember, only if we use from django.test import TestCase, which is the one we should always use):
<QuerySet []>Tests that require a database will not use the "real" (production or development) database of the project. Separate, blank databases are created for testing.
From now on, to do the tests, you must use:
from django.test import TestCaseAnd not the other one, which is provided by Python:
import unittest.TestCaseSince, the class provided by Django (from django.test import TestCase) does not confirm (commit) the data that we are manipulating in the unit tests and by default, it does not use the records stored in the database, unlike the package Python (import unittest.TestCase) that confirms the operations.
In summary, if you make connections from django.test import TestCase, the context of the configured application is used (which in our case would be the development environment), but if you use import unittest.TestCase, a separate environment provided by Django is used for testing.
With this, we can know the essence of tests, it allows us to create our controlled environment and it is the same every time we run the tests, since, the following steps happen:
- The environment is built
- We as a developer, create the test data and other instances to be used, for that, we can use the method setUp():
from django.test import Client, TestCase
class ContactTests(TestCase):
def setUp(self):
Comment.objects.create(text='text 1')
Comment.objects.create(text='text 2')
self.comments = Comment.objects.all()
self.client = Client()- Create the tests (the rest of the methods in the class).
- Django automatically eliminates the previous environment, therefore, for the example that we defined in the previous example, that we created 2 comments, they are automatically destroyed at the end of the test, which is excellent since we can always have an ideal environment, totally controlled by us (including the number of records and their structure), which could not be the case if the environment were not destroyed).
With what has been discussed in this section, you can have a good idea of how tests work and take advantage of this knowledge to create custom unit tests.
Comments Crud
In this section, we will see how to carry out tests for the CRUD of the comments, the developments carried out in this section can be taken as experiments that will help us understand Django tests based on what we experienced in the previous section.
Index
Since we know that, at the time of executing the tests, we cannot have access to the data that we have defined in the development mode, if not, we must create it ourselves when executing the test in the test environment, we are going to configure the initial data as follows:
comments/tests.py
# import unittest
from django.test import Client, TestCase
from .models import Comment
class ContactTests(TestCase):
def setUp(self):
Comment.objects.create(text='text 1')
Comment.objects.create(text='text 2')
self.comments = Comment.objects.all()
print(self.comments)
self.client = Client()As we mentioned previously, the setUp() method is used to initialize data, for our test it would be the client and the test data that we can use throughout ALL the tests for the comments CRUD, it is important to note that we have a pagination two levels:
comments/views.py
def index(request):
comments = Comment.objects.all()
paginator = Paginator(comments,2)
***And that is why we only create 2 comments, since, apart from checking the status code, it is essential that we check the returned content, which in this example, would be the paginated comments listed in the table:
comments/tests.py
class ContactTests(TestCase):
def test_index(self):
response = self.client.get("/tasks/")
self.assertEqual(response.status_code, 200)
self.assertContains(response,self.comments[0].text)
self.assertContains(response,self.comments[1].text)You can use a for to iterate the comments, but, for the readability of the exercise, in the book we prefer to use it in the way presented. With this, we are testing both the status code, which is the first thing we should always check, and also some of the content using the assertion type methods.
As we mentioned before, how you want to implement the tests and what to test depends entirely on the developer and you can adapt the previous exercise to your needs. Also, for the simplicity of the exercise, we do not test the pagination itself, or the records that are in the pagination and that is why we create only two records, since, if you create a third:
comments/tests.py
class ContactTests(TestCase):
def setUp(self):
Comment.objects.create(text='text 1')
Comment.objects.create(text='text 2')
Comment.objects.create(text='text 3')
self.comments = Comment.objects.all()
print(self.comments)
self.client = Client()
def test_index(self):
response = self.client.get("/tasks/")
self.assertEqual(response.status_code, 200)
self.assertContains(response,self.comments[0].text)
self.assertContains(response,self.comments[1].text)
self.assertContains(response,self.comments[2].text)You will see that when you run the test, it returns an error for not finding the third record since this example has two-level pagination.
Finally run the test and evaluate the result:
$ python manage.py testGenerating tests with AI - Gemini Agent
Testing elements from scratch is tedious because it is a more heavy-duty relationship. Years ago it would have had to be done manually, but today we have AI.
- My philosophy is clear: AI is excellent when we already have a "pool" of data and we want it to replicate a structure or extract information in another format. In this case, we will use it to take our comment tests as a reference and generate the rest for us.
- AI is a tool. To use it at 100%, you must know the Django and unit testing concepts we have seen. If you ask it for tests without knowing how your environment works, the result will not be good.
Defining the Prompt for Gemini
To have the AI help us, we can use two approaches. I used Gemini with a specific prompt:
"Act as an expert Django developer. I want you to generate unit tests for the elements module (specifically the ViewSet and the Serializer), exactly following the style of the api_test.py file that I already have."
It is vital to be specific. If you just ask it to "make tests," the AI may wander. By giving it context and a reference (our previous tests), the generated code will maintain the same tone and structure.
The AI generated tests for the elements ViewSet, including:
- Serialization tests.
- Status code verification (200 OK, 201 Created).
- Pagination tests.
Generating tests for views and forms
For the next module, we are going to generate tests for the rest of the modules, the views and comments. For this, I used the following prompt:
Act as an expert Django developer. I want you to generate unit tests for the elements module (test_form, test_views), following exactly the style of the comments/tests module.
There were some technical details, such as authentication errors or problems with forms that were not ModelForm. For example, in a form test, the AI tried to use a .save() method on a normal Django form. Upon detecting the 500 error, I asked it to fix it:
"Adapt the test because it is a normal Django form, not a ModelForm."
The AI corrected the logic, validating the field manually and verifying persistence in the database. It is an iterative process: you test, detect the error, inform the AI, and correct.
Final Recommendations
If you want to go deeper, I recommend tools more integrated into the IDE like Cursor or Google Anti-gravity, which allow for more solid planning before spitting out code. The volume of code you request influences precision: the higher the volume, the higher the risk of errors, which is likely what will happen when executing these prompts. As always, before requesting that Gemini generate the tests, make a backup of the code, verify, back up again, and so on until you finish the development.
Next step, continuing with the development aspects, learn how to use .env files to perform different configurations for each environment and environment variables in Django.
I agree to receive announcements of interest about this Blog.
We will talk about the different configurations that can be used when performing tests in Django, such as configuration of the test database, types of TestCase classes, among others.