Django provides a comprehensive suite of testing tools that make it easier to write effective tests for web applications. These tools include utilities for creating test data, mocking external dependencies, measuring test coverage, and debugging test failures.
Fixtures provide a way to pre-populate your test database with data:
# Creating fixtures
# 1. Create test data in your application
python manage.py shell
>>> from blog.models import BlogPost, Category
>>> from django.contrib.auth.models import User
>>>
>>> user = User.objects.create_user('testuser', 'test@example.com', 'pass')
>>> category = Category.objects.create(name='Technology', slug='technology')
>>> post = BlogPost.objects.create(
... title='Test Post',
... content='Test content',
... author=user,
... category=category
... )
# 2. Export data to fixture file
python manage.py dumpdata blog.BlogPost blog.Category auth.User --indent=2 > blog/fixtures/test_data.json
# 3. Use fixtures in tests
class BlogPostTests(TestCase):
"""Test BlogPost with fixtures"""
fixtures = ['test_data.json']
def test_post_exists(self):
"""Test that fixture data is loaded"""
post = BlogPost.objects.get(title='Test Post')
self.assertEqual(post.title, 'Test Post')
self.assertEqual(post.author.username, 'testuser')
# YAML fixtures (more readable)
# blog/fixtures/test_data.yaml
- model: auth.user
pk: 1
fields:
username: testuser
email: test@example.com
password: pbkdf2_sha256$...
- model: blog.category
pk: 1
fields:
name: Technology
slug: technology
- model: blog.blogpost
pk: 1
fields:
title: Test Post
content: Test content
author: 1
category: 1
created_at: 2023-01-01 12:00:00
Factory Boy provides a more flexible approach to test data creation:
# Install factory-boy
# pip install factory-boy
# factories.py
import factory
from factory.django import DjangoModelFactory
from django.contrib.auth.models import User
from blog.models import BlogPost, Category, Tag
class UserFactory(DjangoModelFactory):
"""Factory for User model"""
class Meta:
model = User
username = factory.Sequence(lambda n: f'user{n}')
email = factory.LazyAttribute(lambda obj: f'{obj.username}@example.com')
first_name = factory.Faker('first_name')
last_name = factory.Faker('last_name')
is_active = True
@factory.post_generation
def password(self, create, extracted, **kwargs):
"""Set password after user creation"""
if not create:
return
password = extracted or 'defaultpass123'
self.set_password(password)
self.save()
class CategoryFactory(DjangoModelFactory):
"""Factory for Category model"""
class Meta:
model = Category
name = factory.Faker('word')
slug = factory.LazyAttribute(lambda obj: obj.name.lower())
description = factory.Faker('text', max_nb_chars=200)
class TagFactory(DjangoModelFactory):
"""Factory for Tag model"""
class Meta:
model = Tag
name = factory.Faker('word')
slug = factory.LazyAttribute(lambda obj: obj.name.lower())
class BlogPostFactory(DjangoModelFactory):
"""Factory for BlogPost model"""
class Meta:
model = BlogPost
title = factory.Faker('sentence', nb_words=4)
slug = factory.LazyAttribute(
lambda obj: obj.title.lower().replace(' ', '-').replace('.', '')
)
content = factory.Faker('text', max_nb_chars=1000)
author = factory.SubFactory(UserFactory)
category = factory.SubFactory(CategoryFactory)
status = 'published'
@factory.post_generation
def tags(self, create, extracted, **kwargs):
"""Add tags to the post"""
if not create:
return
if extracted:
for tag in extracted:
self.tags.add(tag)
# Advanced factory features
class PublishedPostFactory(BlogPostFactory):
"""Factory for published posts only"""
status = 'published'
published_at = factory.LazyFunction(timezone.now)
class DraftPostFactory(BlogPostFactory):
"""Factory for draft posts only"""
status = 'draft'
published_at = None
class PostWithCommentsFactory(BlogPostFactory):
"""Factory that creates post with comments"""
@factory.post_generation
def comments(self, create, extracted, **kwargs):
"""Create comments for the post"""
if not create:
return
comment_count = extracted or 3
for _ in range(comment_count):
CommentFactory(post=self)
# Using factories in tests
class BlogPostFactoryTests(TestCase):
"""Test using factories"""
def test_basic_factory_usage(self):
"""Test basic factory usage"""
post = BlogPostFactory()
self.assertIsNotNone(post.title)
self.assertIsNotNone(post.author)
self.assertIsNotNone(post.category)
def test_factory_with_custom_attributes(self):
"""Test factory with custom attributes"""
user = UserFactory(username='customuser')
post = BlogPostFactory(
title='Custom Title',
author=user,
status='draft'
)
self.assertEqual(post.title, 'Custom Title')
self.assertEqual(post.author.username, 'customuser')
self.assertEqual(post.status, 'draft')
def test_factory_traits(self):
"""Test factory traits and subfactories"""
# Create published post
published_post = PublishedPostFactory()
self.assertEqual(published_post.status, 'published')
self.assertIsNotNone(published_post.published_at)
# Create draft post
draft_post = DraftPostFactory()
self.assertEqual(draft_post.status, 'draft')
self.assertIsNone(draft_post.published_at)
def test_batch_creation(self):
"""Test creating multiple objects"""
posts = BlogPostFactory.create_batch(5)
self.assertEqual(len(posts), 5)
self.assertEqual(BlogPost.objects.count(), 5)
def test_build_vs_create(self):
"""Test difference between build and create"""
# build() creates object in memory without saving
built_post = BlogPostFactory.build()
self.assertIsNone(built_post.pk)
# create() saves object to database
created_post = BlogPostFactory.create()
self.assertIsNotNone(created_post.pk)
Mock external dependencies to isolate code under test:
from unittest.mock import Mock, patch, MagicMock
from django.test import TestCase
from blog.models import BlogPost
from blog.services import EmailService, SocialMediaService
class MockingExampleTests(TestCase):
"""Examples of mocking in Django tests"""
@patch('blog.services.EmailService.send_email')
def test_post_publication_sends_email(self, mock_send_email):
"""Test that publishing a post sends notification email"""
# Arrange
post = BlogPostFactory(status='draft')
mock_send_email.return_value = True
# Act
post.publish()
# Assert
self.assertEqual(post.status, 'published')
mock_send_email.assert_called_once_with(
subject=f'New post published: {post.title}',
recipient=post.author.email,
template='blog/post_published.html',
context={'post': post}
)
@patch('requests.get')
def test_external_api_integration(self, mock_get):
"""Test integration with external API"""
# Mock API response
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
'status': 'success',
'data': {'shares': 42}
}
mock_get.return_value = mock_response
# Test the service
service = SocialMediaService()
shares = service.get_share_count('http://example.com/post/1/')
# Verify results
self.assertEqual(shares, 42)
mock_get.assert_called_once_with(
'https://api.socialmedia.com/shares',
params={'url': 'http://example.com/post/1/'}
)
def test_mock_with_side_effects(self):
"""Test mock with side effects"""
# Mock that raises exception on first call, succeeds on second
mock_service = Mock()
mock_service.process.side_effect = [
ConnectionError('Network error'),
{'status': 'success'}
]
# Test retry logic
with patch('blog.services.external_service', mock_service):
# First call should handle exception
result = retry_external_call()
# Should have been called twice (retry)
self.assertEqual(mock_service.process.call_count, 2)
self.assertEqual(result['status'], 'success')
@patch.object(BlogPost, 'save')
def test_mock_model_method(self, mock_save):
"""Test mocking model methods"""
post = BlogPost(title='Test', content='Content')
post.save()
# Verify save was called
mock_save.assert_called_once()
def test_context_manager_mocking(self):
"""Test using mock as context manager"""
with patch('blog.utils.send_notification') as mock_notify:
mock_notify.return_value = True
# Code that uses send_notification
result = process_user_action('like_post', user_id=1, post_id=1)
self.assertTrue(result)
mock_notify.assert_called_once()
# Mock Django settings
class SettingsMockTests(TestCase):
"""Test mocking Django settings"""
@patch('django.conf.settings.EMAIL_BACKEND', 'django.core.mail.backends.console.EmailBackend')
def test_with_different_email_backend(self):
"""Test with different email backend"""
from django.core.mail import send_mail
# This will use console backend instead of configured backend
send_mail(
'Test Subject',
'Test message',
'from@example.com',
['to@example.com']
)
@patch.dict('os.environ', {'DEBUG': 'True'})
def test_with_environment_variables(self):
"""Test with mocked environment variables"""
import os
self.assertEqual(os.environ.get('DEBUG'), 'True')
# Advanced mocking patterns
class AdvancedMockingTests(TestCase):
"""Advanced mocking patterns"""
def setUp(self):
"""Set up mocks for all tests"""
self.email_patcher = patch('blog.services.EmailService')
self.mock_email_service = self.email_patcher.start()
self.api_patcher = patch('blog.services.ExternalAPI')
self.mock_api = self.api_patcher.start()
def tearDown(self):
"""Clean up patches"""
self.email_patcher.stop()
self.api_patcher.stop()
def test_with_setup_mocks(self):
"""Test using mocks set up in setUp"""
self.mock_email_service.send.return_value = True
self.mock_api.get_data.return_value = {'key': 'value'}
# Test code that uses both services
result = complex_business_logic()
self.assertTrue(result)
self.mock_email_service.send.assert_called()
self.mock_api.get_data.assert_called()
from django.test import TestCase, override_settings
from django.core.cache import cache
from django.utils import timezone
class DjangoMockingTests(TestCase):
"""Django-specific mocking examples"""
@override_settings(USE_TZ=False)
def test_without_timezone(self):
"""Test with timezone disabled"""
# Code that behaves differently with/without timezone
now = timezone.now()
self.assertIsNone(now.tzinfo)
@override_settings(
CACHES={
'default': {
'BACKEND': 'django.core.cache.backends.dummy.DummyCache',
}
}
)
def test_with_dummy_cache(self):
"""Test with dummy cache backend"""
# Cache operations will be no-ops
cache.set('key', 'value')
self.assertIsNone(cache.get('key'))
@override_settings(DEBUG=True)
def test_debug_mode(self):
"""Test with debug mode enabled"""
from django.conf import settings
self.assertTrue(settings.DEBUG)
def test_mock_timezone_now(self):
"""Test mocking timezone.now()"""
fixed_time = timezone.datetime(2023, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
with patch('django.utils.timezone.now', return_value=fixed_time):
post = BlogPostFactory()
# Post creation time should be our fixed time
self.assertEqual(post.created_at, fixed_time)
def test_mock_user_permissions(self):
"""Test mocking user permissions"""
user = UserFactory()
# Mock has_perm method
with patch.object(user, 'has_perm', return_value=True):
self.assertTrue(user.has_perm('blog.add_post'))
# Mock user groups
with patch.object(user, 'groups') as mock_groups:
mock_groups.filter.return_value.exists.return_value = True
# Test code that checks group membership
self.assertTrue(user_in_group(user, 'editors'))
Measure test coverage to identify untested code:
# Install coverage
pip install coverage
# Run tests with coverage
coverage run --source='.' manage.py test
# Generate coverage report
coverage report
# Generate HTML coverage report
coverage html
# View coverage in browser
open htmlcov/index.html
# .coveragerc - Coverage configuration
[run]
source = .
omit =
*/venv/*
*/migrations/*
*/settings/*
*/tests/*
manage.py
*/node_modules/*
*/static/*
[report]
exclude_lines =
pragma: no cover
def __repr__
raise AssertionError
raise NotImplementedError
if __name__ == .__main__.:
class .*\(Protocol\):
@(abc\.)?abstractmethod
[html]
directory = htmlcov
# GitHub Actions with coverage
- name: Run tests with coverage
run: |
coverage run --source='.' manage.py test
coverage xml
coverage report --fail-under=80
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
flags: unittests
fail_ci_if_error: true
# Example of improving coverage
class BlogPostService:
"""Service with methods to test"""
def create_post(self, title, content, author):
"""Create a new blog post"""
if not title:
raise ValueError("Title is required") # Test this path
if len(title) > 200:
raise ValueError("Title too long") # Test this path
post = BlogPost.objects.create(
title=title,
content=content,
author=author
)
# Send notification (test this path)
if post.author.email:
self.send_notification(post)
return post
def send_notification(self, post):
"""Send notification about new post"""
try:
# Test success path
send_email(post.author.email, f'New post: {post.title}')
return True
except Exception as e:
# Test exception path
logger.error(f'Failed to send notification: {e}')
return False
class BlogPostServiceTests(TestCase):
"""Comprehensive tests for BlogPostService"""
def setUp(self):
self.service = BlogPostService()
self.user = UserFactory(email='test@example.com')
def test_create_post_success(self):
"""Test successful post creation"""
post = self.service.create_post(
title='Test Post',
content='Test content',
author=self.user
)
self.assertEqual(post.title, 'Test Post')
self.assertEqual(post.author, self.user)
def test_create_post_without_title_fails(self):
"""Test post creation without title"""
with self.assertRaises(ValueError) as cm:
self.service.create_post(
title='',
content='Test content',
author=self.user
)
self.assertEqual(str(cm.exception), 'Title is required')
def test_create_post_title_too_long_fails(self):
"""Test post creation with title too long"""
long_title = 'A' * 201
with self.assertRaises(ValueError) as cm:
self.service.create_post(
title=long_title,
content='Test content',
author=self.user
)
self.assertEqual(str(cm.exception), 'Title too long')
@patch('blog.services.send_email')
def test_notification_sent_for_user_with_email(self, mock_send_email):
"""Test notification sent when user has email"""
mock_send_email.return_value = True
post = self.service.create_post(
title='Test Post',
content='Test content',
author=self.user
)
mock_send_email.assert_called_once_with(
'test@example.com',
'New post: Test Post'
)
def test_no_notification_for_user_without_email(self):
"""Test no notification sent when user has no email"""
user_without_email = UserFactory(email='')
with patch('blog.services.send_email') as mock_send_email:
post = self.service.create_post(
title='Test Post',
content='Test content',
author=user_without_email
)
mock_send_email.assert_not_called()
@patch('blog.services.send_email')
@patch('blog.services.logger')
def test_notification_failure_handling(self, mock_logger, mock_send_email):
"""Test notification failure is handled gracefully"""
mock_send_email.side_effect = Exception('Email service down')
# Should not raise exception
result = self.service.send_notification(
BlogPostFactory(author=self.user)
)
self.assertFalse(result)
mock_logger.error.assert_called_once()
import pdb
from django.test import TestCase
from django.test.utils import override_settings
class DebuggingTests(TestCase):
"""Test debugging examples"""
def test_with_debugger(self):
"""Test with Python debugger"""
post = BlogPostFactory()
# Set breakpoint for debugging
# pdb.set_trace() # Uncomment to debug
self.assertEqual(post.status, 'published')
def test_with_print_debugging(self):
"""Test with print statements"""
posts = BlogPostFactory.create_batch(3)
# Debug output
print(f"Created {len(posts)} posts")
for post in posts:
print(f"Post: {post.title} - {post.status}")
self.assertEqual(len(posts), 3)
@override_settings(DEBUG=True)
def test_with_debug_mode(self):
"""Test with Django debug mode"""
from django.conf import settings
from django.db import connection
# Clear queries
connection.queries_log.clear()
# Perform database operations
posts = list(BlogPost.objects.all())
# Check query count
query_count = len(connection.queries)
print(f"Executed {query_count} queries")
for query in connection.queries:
print(f"Query: {query['sql']}")
def test_assertion_debugging(self):
"""Test with detailed assertion messages"""
post = BlogPostFactory(title='Test Post')
# Detailed assertion messages
self.assertEqual(
post.title,
'Test Post',
f"Expected title 'Test Post', got '{post.title}'"
)
self.assertIn(
'Test',
post.title,
f"Expected 'Test' in title '{post.title}'"
)
# Custom assertion methods
class CustomAssertionTests(TestCase):
"""Custom assertion methods for better debugging"""
def assertPostValid(self, post):
"""Custom assertion for post validation"""
self.assertIsNotNone(post.title, "Post title should not be None")
self.assertIsNotNone(post.content, "Post content should not be None")
self.assertIsNotNone(post.author, "Post author should not be None")
self.assertIn(post.status, ['draft', 'published'],
f"Invalid post status: {post.status}")
def assertResponseContainsPost(self, response, post):
"""Custom assertion for response containing post"""
self.assertEqual(response.status_code, 200)
self.assertContains(response, post.title)
self.assertContains(response, post.content)
self.assertContains(response, post.author.username)
def test_with_custom_assertions(self):
"""Test using custom assertions"""
post = BlogPostFactory()
self.assertPostValid(post)
response = self.client.get(f'/blog/{post.slug}/')
self.assertResponseContainsPost(response, post)
import cProfile
import pstats
from django.test import TestCase
class PerformanceTests(TestCase):
"""Test performance profiling"""
def test_with_profiling(self):
"""Test with performance profiling"""
profiler = cProfile.Profile()
profiler.enable()
# Code to profile
posts = BlogPostFactory.create_batch(100)
for post in posts:
post.get_reading_time()
profiler.disable()
# Analyze results
stats = pstats.Stats(profiler)
stats.sort_stats('cumulative')
stats.print_stats(10) # Top 10 functions
def test_query_performance(self):
"""Test database query performance"""
from django.test.utils import override_settings
from django.db import connection
with override_settings(DEBUG=True):
connection.queries_log.clear()
# Test query performance
posts = list(BlogPost.objects.select_related('author', 'category'))
query_count = len(connection.queries)
self.assertLessEqual(query_count, 1,
f"Expected 1 query, got {query_count}")
# test_mixins.py
from django.test import TestCase
from django.contrib.auth.models import User
class AuthenticatedTestMixin:
"""Mixin for tests requiring authenticated user"""
def setUp(self):
super().setUp()
self.user = UserFactory()
self.client.force_login(self.user)
class AdminTestMixin:
"""Mixin for tests requiring admin user"""
def setUp(self):
super().setUp()
self.admin_user = UserFactory(is_staff=True, is_superuser=True)
self.client.force_login(self.admin_user)
class AjaxTestMixin:
"""Mixin for AJAX request testing"""
def ajax_get(self, url, data=None):
"""Make AJAX GET request"""
return self.client.get(
url,
data or {},
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
)
def ajax_post(self, url, data=None):
"""Make AJAX POST request"""
return self.client.post(
url,
data or {},
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
)
# Using mixins
class BlogPostViewTests(AuthenticatedTestMixin, AjaxTestMixin, TestCase):
"""Test blog post views with mixins"""
def test_create_post_ajax(self):
"""Test creating post via AJAX"""
response = self.ajax_post('/blog/create/', {
'title': 'New Post',
'content': 'Post content'
})
self.assertEqual(response.status_code, 201)
self.assertEqual(response['Content-Type'], 'application/json')
# test_assertions.py
from django.test import TestCase
class CustomAssertionsMixin:
"""Custom assertions for Django testing"""
def assertRedirectsToLogin(self, response, login_url='/login/'):
"""Assert response redirects to login page"""
self.assertEqual(response.status_code, 302)
self.assertTrue(response.url.startswith(login_url))
def assertJsonResponse(self, response, expected_data=None):
"""Assert response is valid JSON"""
self.assertEqual(response['Content-Type'], 'application/json')
if expected_data:
import json
actual_data = json.loads(response.content)
self.assertEqual(actual_data, expected_data)
def assertEmailSent(self, subject=None, recipient=None):
"""Assert email was sent"""
from django.core import mail
self.assertGreater(len(mail.outbox), 0, "No emails were sent")
if subject:
self.assertIn(subject, [email.subject for email in mail.outbox])
if recipient:
recipients = [email.to for email in mail.outbox]
flat_recipients = [addr for sublist in recipients for addr in sublist]
self.assertIn(recipient, flat_recipients)
def assertQueryCountEqual(self, expected_count):
"""Context manager to assert query count"""
from django.test.utils import override_settings
from django.db import connection
class QueryCountContext:
def __enter__(self):
connection.queries_log.clear()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
actual_count = len(connection.queries)
if actual_count != expected_count:
queries = '\n'.join([q['sql'] for q in connection.queries])
raise AssertionError(
f"Expected {expected_count} queries, got {actual_count}:\n{queries}"
)
return QueryCountContext()
# Using custom assertions
class BlogPostTests(CustomAssertionsMixin, TestCase):
"""Test using custom assertions"""
def test_login_required_view(self):
"""Test view requires login"""
response = self.client.get('/blog/create/')
self.assertRedirectsToLogin(response)
def test_api_response(self):
"""Test API response format"""
response = self.client.get('/api/posts/')
self.assertJsonResponse(response, {'posts': []})
def test_post_creation_sends_email(self):
"""Test post creation sends notification email"""
user = UserFactory(email='test@example.com')
self.client.force_login(user)
self.client.post('/blog/create/', {
'title': 'New Post',
'content': 'Post content'
})
self.assertEmailSent(
subject='New post published',
recipient='test@example.com'
)
def test_optimized_query(self):
"""Test query optimization"""
BlogPostFactory.create_batch(10)
with self.assertQueryCountEqual(1):
posts = list(BlogPost.objects.select_related('author'))
With a solid understanding of Django's testing tools, you're ready to dive into testing specific components of Django applications. The next chapter will focus on testing models, covering validation, business logic, custom methods, and database interactions.
Key testing tools covered:
These tools form the foundation for writing comprehensive, maintainable test suites that ensure your Django application's reliability and quality.
Writing and Running Tests
Effective test writing and execution are crucial skills for Django developers. This chapter covers practical techniques for creating comprehensive test suites, organizing tests for maintainability, and running tests efficiently during development and in production environments.
Testing Models
Model testing is fundamental to Django application quality. Models contain your application's core business logic, data validation rules, and database interactions. Comprehensive model testing ensures data integrity, validates business rules, and provides confidence when making changes to your data layer.