When you’re testing Django applications with pytest, you’re not just testing isolated Python functions; you’re often testing how your code interacts with the database and how it handles HTTP requests. Pytest-django provides powerful tools to manage these for you, but it’s easy to get tripped up if you don’t understand the underlying mechanics.
Let’s see how pytest-django sets up a fresh database for each test function by default.
# tests/test_models.py
from django.contrib.auth.models import User
from myapp.models import MyModel
def test_create_user(db):
"""
This test uses the 'db' fixture, ensuring a clean database.
"""
assert User.objects.count() == 0
User.objects.create_user(username='testuser', password='password')
assert User.objects.count() == 1
def test_create_mymodel(db):
"""
Another test, also with a clean database.
"""
assert MyModel.objects.count() == 0
MyModel.objects.create(name='Test Item')
assert MyModel.objects.count() == 1
Notice the db argument in each test function. This isn’t a magical keyword; it’s a fixture provided by pytest-django. When you request the db fixture, pytest-django performs a series of actions before running your test. It migrates your database to the latest state, then, after your test completes (whether it passes or fails), it rolls back all the changes. This ensures that each test starts with a pristine, empty database, preventing test pollution.
But what if you need to make actual HTTP requests to your Django application within your tests? This is where the client fixture comes in.
# tests/test_views.py
from django.urls import reverse
from django.contrib.auth.models import User
def test_login_view(client, db):
"""
Tests the login view using the client and a clean database.
"""
User.objects.create_user(username='testuser', password='password')
url = reverse('login') # Assuming you have a 'login' URL pattern
# Test GET request
response = client.get(url)
assert response.status_code == 200
assert 'login.html' in response.template_name
# Test POST request for successful login
login_data = {'username': 'testuser', 'password': 'password'}
response = client.post(url, login_data)
assert response.status_code == 302 # Redirect after successful login
assert response.url == reverse('dashboard') # Assuming redirect to dashboard
# Test POST request for failed login
bad_login_data = {'username': 'wronguser', 'password': 'wrongpassword'}
response = client.post(url, bad_login_data)
assert response.status_code == 200
assert 'login.html' in response.template_name # Stays on login page
assert 'Invalid credentials' in str(response.context['form'].errors)
The client fixture is a subclass of Django’s Client. It mimics the behavior of a web browser, allowing you to send GET, POST, PUT, DELETE, and other HTTP requests to your application’s URLs. Crucially, when you use the client fixture, pytest-django automatically wraps your requests within a transaction that’s rolled back after the test. This means any data created or modified by your requests (like user creation or form submissions) is cleaned up, just like with the db fixture.
The mental model here is that pytest-django orchestrates a transactional test environment. For database operations, it uses Django’s transaction management. For request testing, the client fixture also operates within a transaction. The db fixture ensures that database migrations are applied and that the database is in a known state before each test. The client fixture allows you to interact with your application as if it were a live web server, but with the guarantee of isolation provided by the transactional rollback.
If you find yourself needing to persist data between tests (which is generally discouraged but sometimes necessary for complex integration scenarios), you might look into using the django_db_blocker or custom fixtures that manage transactions differently. However, the default behavior of a clean database and transactional client is the most robust way to write reliable tests.
One common pitfall is forgetting to include the db fixture when your test only performs database operations. While the client fixture implies database activity and will likely pull in the db fixture’s behavior implicitly, it’s good practice to be explicit. Without db, your migrations might not run, leading to OperationalError exceptions when your test tries to access tables that don’t exist yet.
The next logical step after mastering database and request testing is handling asynchronous operations within your Django tests, which often involves the anyio or pytest-asyncio fixtures.