Django TestCase vs pytest-django: Which Testing Approach to Use

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_status

The 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 + 1

The 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 == 200

Mixing 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 setUpTestData for expensive shared state
  • Working with team members unfamiliar with pytest

Use pytest-django when:

  • Starting a new project
  • You need parametrize for 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.

Read more

Start now free