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 == 11Testing 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() == 1assertNumQueries — 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+1With 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 == 200Django 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() > 0Problem 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 articlessetUpTestData 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() == 0Warning: 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 migrationsKey Patterns
- Test model methods and managers in isolation — no HTTP overhead
- Use
django_assert_num_queriesto catch N+1 queries before they reach production - Prefer Python fixtures over JSON fixtures — they stay in sync with schema changes automatically
- Use
setUpTestDatafor expensive read-only shared state - Test constraints with
pytest.raises(IntegrityError)— verify the DB enforces your invariants - Use
refresh_from_db()afterupdate()calls — in-memory objects don't auto-update bulk_createlarge 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.