Django TestCase vs pytest-django: Which Testing Approach to Use
Django ships with a solid test framework built on Python's unittest.TestCase. pytest-django adds pytest's ergonomics on top — fixtures, parametrize, concise assertions — without replacing Django's test infrastructure. Choosing between them shapes how your test suite reads and scales.
Setup
pip install pytest pytest-django factory-boy
# pytest.ini or pyproject.toml# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = myproject.settings.test
python_files = tests.py test_*.py *_test.py# pyproject.toml
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "myproject.settings.test"Django TestCase
Django's TestCase wraps each test in a transaction that's rolled back after the test. It provides database access, client, and assertion helpers out of the box:
# tests/test_views.py
from django.test import TestCase, Client
from django.contrib.auth import get_user_model
from myapp.models import Article
User = get_user_model()
class ArticleViewTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username='testuser', password='testpass'
)
self.article = Article.objects.create(
title='Test Article',
content='Content here',
author=self.user,
)
self.client.login(username='testuser', password='testpass')
def test_article_detail_returns_200(self):
response = self.client.get(f'/articles/{self.article.pk}/')
self.assertEqual(response.status_code, 200)
def test_article_detail_contains_title(self):
response = self.client.get(f'/articles/{self.article.pk}/')
self.assertContains(response, 'Test Article')
def test_unpublished_article_returns_404(self):
unpublished = Article.objects.create(
title='Draft',
content='...',
author=self.user,
published=False,
)
response = self.client.get(f'/articles/{unpublished.pk}/')
self.assertEqual(response.status_code, 404)TestCase Class Hierarchy
| Class | DB Access | Transactions | Use For |
|---|---|---|---|
TestCase |
Yes | Rolled back per test | Most tests |
TransactionTestCase |
Yes | Committed and flushed | Testing transactions, signals |
SimpleTestCase |
No | — | Pure logic, no DB |
LiveServerTestCase |
Yes | Rolled back | Selenium / browser tests |
pytest-django
pytest-django turns Django tests into plain functions with injected fixtures:
# tests/test_views.py
import pytest
from django.contrib.auth import get_user_model
from myapp.models import Article
User = get_user_model()
@pytest.fixture
def user(db):
return User.objects.create_user(username='testuser', password='testpass')
@pytest.fixture
def article(db, user):
return Article.objects.create(
title='Test Article',
content='Content here',
author=user,
)
@pytest.mark.django_db
def test_article_detail_returns_200(client, article, user):
client.login(username='testuser', password='testpass')
response = client.get(f'/articles/{article.pk}/')
assert response.status_code == 200
@pytest.mark.django_db
def test_article_detail_contains_title(client, article, user):
client.login(username='testuser', password='testpass')
response = client.get(f'/articles/{article.pk}/')
assert b'Test Article' in response.content
@pytest.mark.django_db
def test_unauthenticated_access_redirects(client, article):
response = client.get(f'/articles/{article.pk}/')
assert response.status_code == 302
assert '/login/' in response['Location']Key Differences
Fixtures vs setUp
setUp runs before every test in the class — you can't easily share setup across files. pytest fixtures are composable and reusable across your whole test suite:
# conftest.py — shared across all tests
import pytest
from django.contrib.auth import get_user_model
from myapp.models import Article, Category
User = get_user_model()
@pytest.fixture
def user(db):
return User.objects.create_user(
username='alice', email='alice@example.com', password='pass'
)
@pytest.fixture
def admin_user(db):
return User.objects.create_superuser(
username='admin', email='admin@example.com', password='admin'
)
@pytest.fixture
def category(db):
return Category.objects.create(name='Technology')
@pytest.fixture
def article(db, user, category):
return Article.objects.create(
title='Sample Article',
content='Body text',
author=user,
category=category,
)Parametrize
pytest's parametrize tests multiple inputs with a single test function:
import pytest
@pytest.mark.parametrize('username,password,expected_status', [
('alice', 'correct', 200),
('alice', 'wrong', 401),
('unknown', 'pass', 401),
('', '', 401),
])
@pytest.mark.django_db
def test_login_status_codes(client, user, username, password, expected_status):
response = client.post('/api/login/', {
'username': username,
'password': password,
}, content_type='application/json')
assert response.status_code == expected_statusThe equivalent with TestCase requires a loop or separate methods.
Database Access Markers
# @pytest.mark.django_db — standard, uses transactions
# @pytest.mark.django_db(transaction=True) — like TransactionTestCase
# @pytest.mark.django_db(databases=['default', 'analytics']) — multi-db
@pytest.mark.django_db(transaction=True)
def test_signal_fires_on_save(user):
# Test that requires committed data visible to signal handlers
from myapp.signals import post_save_counter
initial = post_save_counter.count
Article.objects.create(title='New', content='...', author=user)
assert post_save_counter.count == initial + 1The rf and client Fixtures
from django.contrib.auth import get_user_model
User = get_user_model()
def test_view_with_request_factory(rf, user):
"""rf is a RequestFactory instance"""
from myapp.views import ArticleListView
request = rf.get('/articles/')
request.user = user
response = ArticleListView.as_view()(request)
assert response.status_code == 200
def test_view_with_authenticated_client(client, user):
"""client is Django's test client"""
client.force_login(user) # No password needed
response = client.get('/articles/')
assert response.status_code == 200Mixing Both Approaches
You can use pytest-django fixtures inside TestCase subclasses with @pytest.mark.usefixtures:
@pytest.mark.django_db
class TestArticleModel(TestCase):
"""Use TestCase for grouping but get pytest's output"""
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(username='bob', password='pass')
def test_str_representation(self):
article = Article(title='My Post', author=self.user)
self.assertEqual(str(article), 'My Post')setUpTestData creates objects once per class (not per test) — faster than setUp for read-only data.
Pytest-only Patterns Worth Adopting
Autouse Fixtures
@pytest.fixture(autouse=True)
def reset_cache():
"""Clear cache before every test automatically"""
from django.core.cache import cache
cache.clear()
yield
cache.clear()Mocking with mocker
def test_sends_welcome_email(mocker, user, client):
mock_send = mocker.patch('myapp.tasks.send_welcome_email.delay')
client.post('/api/register/', {
'username': 'newuser',
'email': 'new@example.com',
'password': 'secure123',
})
mock_send.assert_called_once_with('new@example.com')Settings Override
@pytest.mark.django_db
def test_feature_flag_disabled(settings, client, article):
settings.FEATURE_NEW_DESIGN = False
response = client.get(f'/articles/{article.pk}/')
assert 'new-design' not in response.content.decode()When to Use Each
Use TestCase when:
- Inheriting legacy test code
- You need
setUpTestDatafor expensive shared state - Working with team members unfamiliar with pytest
Use pytest-django when:
- Starting a new project
- You need
parametrizefor data-driven tests - Sharing fixtures across test files
- You want concise assertion output on failures
Most Django projects benefit from pytest-django. The @pytest.mark.django_db decorator is explicit (you know which tests hit the DB), fixtures are composable, and parametrize eliminates repetitive test cases.