Writing code without tests is like building a house without a foundation. It feels fast at first. You see progress quickly. But soon every change becomes scary. You fix one bug and create two more. You become afraid to refactor or add new features. This is the fastest way to kill a project.
The goal of testing is not to reach 100% code coverage or to satisfy some abstract notion of quality. The goal is confidence. Confidence to change code. Confidence to ship new features. Confidence to know that the core parts of your application work as you expect.
Many developers get stuck on where to start. Django’s built in testing tools can feel verbose. The good news is there is a simpler way.
unittest
Use PytestDjango comes with a testing framework based on Python’s built in unittest
module. It works but it’s often verbose. You have to create classes that inherit from TestCase
and use special assertion methods like self.assertEqual
.
There is a better tool called Pytest. It lets you write tests as simple Python functions. Its assertions are just the standard assert
keyword. It is cleaner more readable and more powerful.
Getting started is simple. First install Pytest and the Django plugin.
pip install pytest pytest-django
Then create a file in the root of your project called pytest.ini
. This tells Pytest where to find your Django settings.
[pytest]
DJANGO_SETTINGS_MODULE = your_project.settings
python_files = tests.py test_*.py *_tests.py
Now you can run your tests with a single command.
pytest
Of course you have no tests yet. Let’s write one.
The easiest place to start testing is with your models. Imagine you have a simple Post
model for a blog.
# blog/models.py
from django.db import models
from django.contrib.auth.models import User
class Post(models.Model):
author = models.ForeignKey(User, on_delete=models.CASCADE)
title = models.CharField(max_length=200)
body = models.TextField()
published_at = models.DateTimeField(null=True, blank=True)
def is_published(self):
return self.published_at is not None
The is_published
method is a small piece of logic. It is a perfect candidate for a test.
Let’s create a test file. The convention is to put tests in a tests.py
file inside your app directory or a tests/
folder.
# blog/tests.py
import pytest
from django.contrib.auth.models import User
from .models import Post
@pytest.mark.django_db
def test_post_is_published():
user = User.objects.create(username='testuser')
post = Post.objects.create(author=user, title='Test Post')
assert post.is_published() is False
Let’s break this down. @pytest.mark.django_db
tells Pytest that this test needs access to the database. Pytest will create a fresh empty test database for you so you don’t mess up your real data.
Inside the function we create a User
and a Post
. Then we use a simple assert
statement to check if our is_published
method returns False
as expected. It’s clean and direct.
The test we just wrote works. But it has a problem. We are manually creating a User
and a Post
inside the test function. If we write ten more tests for our Post
model we will have to repeat this setup code every time.
This makes tests brittle and hard to read. A good test should focus only on what it is testing. The setup should be separate. This is what fixtures are for.
A fixture is a function that provides data or objects to your tests. Pytest’s fixture system is powerful and simple. Let’s create a fixture for our Post
model.
It is a good practice to put common fixtures in a conftest.py
file in your app’s directory.
# blog/conftest.py
import pytest
from django.contrib.auth.models import User
from .models import Post
@pytest.fixture
def test_user(db):
return User.objects.create_user(username='testuser', password='password')
@pytest.fixture
def test_post(test_user):
return Post.objects.create(author=test_user, title='My Test Post')
The test_user
fixture creates and returns a user. The db
argument is itself a fixture provided by pytest-django
that gives us database access.
The test_post
fixture is even more interesting. It takes test_user
as an argument. Pytest sees this and automatically runs the test_user
fixture first passing its return value to test_post
. This is called dependency injection and it makes fixtures composable.
Now we can rewrite our test to be much cleaner.
# blog/tests.py
import pytest
# ... other imports
@pytest.mark.django_db
def test_post_is_published(test_post):
assert test_post.is_published() is False
Look how simple that is. The test function now only contains the assertion. All the setup is handled by the test_post
fixture which is passed in as an argument. If we need to change how a post is created we only have to change it in one place the fixture.
Models are a good start but the most important code to test is your business logic. This is the code that makes your application unique. Often this logic gets stuck inside views or large model methods.
A better approach is to pull this logic out into separate functions or classes. This is sometimes called a service layer. This pattern helps keep your code organized as your app grows. You can read more about this idea in When Your Django App Gets Too Big.
Imagine a function that calculates a user’s posting score.
# blog/services.py
from .models import Post
def calculate_user_score(user):
post_count = Post.objects.filter(author=user).count()
# a very simple scoring algorithm
return post_count * 10
Testing this is now very straightforward.
# blog/tests.py
import pytest
from .services import calculate_user_score
from .models import Post
@pytest.mark.django_db
def test_calculate_user_score(test_user):
# No posts yet
score = calculate_user_score(test_user)
assert score == 0
# Add a post
Post.objects.create(author=test_user, title='First Post')
score = calculate_user_score(test_user)
assert score == 10
We can use our test_user
fixture to create a user. Then we can test the service function’s behavior in different states. The logic is isolated making it easy to test thoroughly.
You don’t need to test every single line of your code. Start with the most important parts. Test your custom model methods. Test your service functions. These are the places where bugs are most likely to hide.
Once you have a good foundation of tests for your core logic you can move on to testing your views or API endpoints. But starting with simple unit tests and fixtures gives you the biggest return for your effort.
The goal is to build a safety net. Each test you write is another thread in that net. It makes you bolder and your application stronger. The biggest hurdle is the first one so think about the smallest piece of your application you could protect with a test.
— Rishi Banerjee
September 2025