Django ORM Query Testing and Fixtures: Strategies for Fast, Reliable Tests

Django ORM Query Testing and Fixtures: Strategies for Fast, Reliable Tests

Django's ORM is the heart of most applications — and the source of many bugs. N+1 queries, missing indexes, broken managers, and subtle transaction behaviors are best caught at test time. This guide covers strategies for testing ORM queries, model logic, and test data setup.

Setup

pip install pytest pytest-django factory-boy
# conftest.py
import pytest
from django.contrib.auth import get_user_model

User = get_user_model()

@pytest.fixture
def user(db):
    return User.objects.create_user(username='alice', password='pass')

Testing Model Methods

Test model methods directly — no views, no HTTP:

# myapp/models.py
from django.db import models
from django.utils import timezone


class Article(models.Model):
    title = models.CharField(max_length=255)
    content = models.TextField()
    author = models.ForeignKey('auth.User', on_delete=models.CASCADE)
    published_at = models.DateTimeField(null=True, blank=True)
    view_count = models.PositiveIntegerField(default=0)

    @property
    def is_published(self):
        return self.published_at is not None

    def publish(self):
        if self.is_published:
            raise ValueError('Article already published')
        self.published_at = timezone.now()
        self.save(update_fields=['published_at'])

    def increment_views(self):
        Article.objects.filter(pk=self.pk).update(view_count=models.F('view_count') + 1)

    def __str__(self):
        return self.title
# tests/test_models.py
import pytest
from django.utils import timezone
from myapp.models import Article


@pytest.mark.django_db
class TestArticleModel:
    def test_is_published_false_when_no_date(self, user):
        article = Article.objects.create(
            title='Draft', content='...', author=user
        )
        assert not article.is_published

    def test_is_published_true_when_date_set(self, user):
        article = Article.objects.create(
            title='Published',
            content='...',
            author=user,
            published_at=timezone.now(),
        )
        assert article.is_published

    def test_publish_sets_timestamp(self, user):
        article = Article.objects.create(title='Draft', content='...', author=user)
        before = timezone.now()
        article.publish()
        after = timezone.now()

        article.refresh_from_db()
        assert before <= article.published_at <= after

    def test_publish_raises_if_already_published(self, user):
        article = Article.objects.create(
            title='Published',
            content='...',
            author=user,
            published_at=timezone.now(),
        )
        with pytest.raises(ValueError, match='already published'):
            article.publish()

    def test_increment_views_uses_f_expression(self, user):
        article = Article.objects.create(
            title='Popular', content='...', author=user, view_count=10
        )
        article.increment_views()
        article.refresh_from_db()
        assert article.view_count == 11

Testing Custom Managers and QuerySets

# myapp/managers.py
from django.db import models
from django.utils import timezone


class ArticleQuerySet(models.QuerySet):
    def published(self):
        return self.filter(published_at__isnull=False)

    def by_author(self, user):
        return self.filter(author=user)

    def popular(self, min_views=100):
        return self.filter(view_count__gte=min_views)

    def recent(self, days=30):
        cutoff = timezone.now() - timezone.timedelta(days=days)
        return self.filter(published_at__gte=cutoff)


class ArticleManager(models.Manager):
    def get_queryset(self):
        return ArticleQuerySet(self.model, using=self._db)

    def published(self):
        return self.get_queryset().published()
@pytest.mark.django_db
class TestArticleQuerySet:
    def test_published_excludes_drafts(self, user, db):
        Article.objects.create(title='Draft', content='...', author=user)
        published = Article.objects.create(
            title='Published',
            content='...',
            author=user,
            published_at=timezone.now(),
        )

        results = Article.objects.published()
        assert list(results) == [published]

    def test_by_author_filters_correctly(self, user, db):
        other = User.objects.create_user(username='bob', password='pass')
        mine = Article.objects.create(title='Mine', content='...', author=user)
        Article.objects.create(title='Theirs', content='...', author=other)

        results = Article.objects.by_author(user)
        assert list(results) == [mine]

    def test_popular_with_custom_threshold(self, user):
        Article.objects.create(title='Low', content='...', author=user, view_count=50)
        Article.objects.create(title='High', content='...', author=user, view_count=200)

        results = Article.objects.popular(min_views=100)
        assert results.count() == 1
        assert results.first().title == 'High'

    def test_querysets_chain(self, user):
        # published AND popular AND by author
        Article.objects.create(
            title='Match', content='...', author=user,
            published_at=timezone.now(), view_count=500
        )
        Article.objects.create(
            title='Not popular', content='...', author=user,
            published_at=timezone.now(), view_count=5
        )

        results = (
            Article.objects
            .published()
            .by_author(user)
            .popular(min_views=100)
        )
        assert results.count() == 1

assertNumQueries — Detecting N+1

The most important ORM test: catching queries that scale linearly with data:

@pytest.mark.django_db
def test_article_list_no_n_plus_one(client, user, db):
    # Create 10 articles with authors
    for i in range(10):
        Article.objects.create(
            title=f'Article {i}', content='...', author=user
        )

    # Without select_related, each article access loads the author separately
    with django.test.utils.override_settings(DEBUG=True):
        # 1 query for articles + 1 for auth check
        with django.db.connection.execute_wrapper(query_counter := QueryCounter()):
            response = client.get('/api/articles/')

    # Or use assertNumQueries directly in tests:
    from django.test import TestCase
    # In pytest, use a different approach:
    from django.db import connection, reset_queries
    from django.conf import settings

    settings.DEBUG = True
    reset_queries()

    list(Article.objects.select_related('author').all())

    assert len(connection.queries) == 1  # Single JOIN query, not N+1

With pytest-django, use django_assert_num_queries:

@pytest.mark.django_db
def test_article_list_query_count(authenticated_client, user, db, django_assert_num_queries):
    Article.objects.bulk_create([
        Article(title=f'Article {i}', content='...', author=user)
        for i in range(5)
    ])

    with django_assert_num_queries(2):  # 1 for auth, 1 for articles
        response = authenticated_client.get('/api/articles/')
    assert response.status_code == 200

Django Fixtures (JSON)

Django's built-in fixtures load data from JSON/YAML/XML files:

# Export current data as fixture
python manage.py dumpdata myapp.Article --indent 2 > myapp/fixtures/articles.json

# Load in tests
@pytest.mark.django_db
class TestWithFixtures:
    fixtures = ['articles.json', 'users.json']

    def test_fixture_data_loaded(self):
        assert Article.objects.count() > 0

Problem with JSON fixtures: They're fragile. Primary keys are hardcoded, foreign keys break easily, and keeping them in sync with schema changes is painful.

pytest-django Fixtures (Python)

Python fixtures via conftest are more maintainable:

# conftest.py
import pytest
from myapp.models import Article, Category, Tag
from django.contrib.auth import get_user_model

User = get_user_model()


@pytest.fixture
def categories(db):
    return {
        'tech': Category.objects.create(name='Technology', slug='technology'),
        'science': Category.objects.create(name='Science', slug='science'),
    }


@pytest.fixture
def tags(db):
    return {
        'python': Tag.objects.create(name='Python'),
        'django': Tag.objects.create(name='Django'),
    }


@pytest.fixture
def published_articles(db, user, categories, tags):
    from django.utils import timezone
    articles = []
    for i in range(5):
        article = Article.objects.create(
            title=f'Article {i}',
            content=f'Content {i}',
            author=user,
            category=categories['tech'],
            published_at=timezone.now(),
        )
        article.tags.set([tags['python'], tags['django']])
        articles.append(article)
    return articles

setUpTestData for Expensive Shared State

from django.test import TestCase


class TestExpensiveSetup(TestCase):
    @classmethod
    def setUpTestData(cls):
        """Runs once per class, not per test method"""
        cls.user = User.objects.create_user(username='alice', password='pass')
        # Create 1000 articles once
        Article.objects.bulk_create([
            Article(title=f'Article {i}', content='...', author=cls.user)
            for i in range(1000)
        ])

    def test_count(self):
        # Uses the data without re-creating it
        assert Article.objects.count() == 1000

    def test_published_count(self):
        assert Article.objects.published().count() == 0

Warning: don't mutate setUpTestData objects in tests — changes persist across test methods.

Testing Database Constraints

from django.db import IntegrityError


@pytest.mark.django_db
def test_unique_slug_constraint(user):
    Article.objects.create(title='First', content='...', author=user, slug='my-slug')

    with pytest.raises(IntegrityError):
        Article.objects.create(title='Second', content='...', author=user, slug='my-slug')


@pytest.mark.django_db
def test_null_title_rejected(user):
    with pytest.raises(Exception):  # IntegrityError or ValidationError
        Article.objects.create(title=None, content='...', author=user)

Testing Migrations

# tests/test_migrations.py
from django.test import TestCase


class TestMigrations(TestCase):
    def test_migrations_are_consistent(self):
        """Fail if there are unapplied migrations"""
        from django.core.management import call_command
        from io import StringIO

        out = StringIO()
        call_command('migrate', '--check', stdout=out, stderr=out)
        # If this raises SystemExit(1), there are pending migrations

Key Patterns

  • Test model methods and managers in isolation — no HTTP overhead
  • Use django_assert_num_queries to catch N+1 queries before they reach production
  • Prefer Python fixtures over JSON fixtures — they stay in sync with schema changes automatically
  • Use setUpTestData for expensive read-only shared state
  • Test constraints with pytest.raises(IntegrityError) — verify the DB enforces your invariants
  • Use refresh_from_db() after update() calls — in-memory objects don't auto-update
  • bulk_create large datasets for pagination and performance tests

ORM tests are fast (no HTTP, often no transactions for pure Python methods) and catch the most data-related bugs. Build a solid layer of ORM tests before adding view or API tests.

Read more

Start now free