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.
# Small application - single tests.py
myapp/
├── tests.py
├── models.py
├── views.py
└── forms.py
# tests.py
from django.test import TestCase
from django.contrib.auth.models import User
from .models import BlogPost, Comment
from .forms import BlogPostForm, CommentForm
class BlogPostModelTests(TestCase):
"""Test BlogPost model"""
pass
class BlogPostViewTests(TestCase):
"""Test BlogPost views"""
pass
class BlogPostFormTests(TestCase):
"""Test BlogPost forms"""
pass
# Larger application - tests package
myapp/
├── tests/
│ ├── __init__.py
│ ├── test_models.py
│ ├── test_views.py
│ ├── test_forms.py
│ ├── test_utils.py
│ ├── factories.py
│ └── base.py
├── models.py
├── views.py
└── forms.py
# tests/base.py - Common test utilities
from django.test import TestCase
from django.contrib.auth.models import User
class BaseTestCase(TestCase):
"""Base test case with common setup"""
def setUp(self):
"""Create common test data"""
self.user = User.objects.create_user(
username='testuser',
email='test@example.com',
password='testpass123'
)
self.admin_user = User.objects.create_superuser(
username='admin',
email='admin@example.com',
password='adminpass123'
)
# tests/test_models.py
from .base import BaseTestCase
from myapp.models import BlogPost
class BlogPostModelTests(BaseTestCase):
"""Test BlogPost model functionality"""
def test_post_creation(self):
"""Test creating a blog post"""
post = BlogPost.objects.create(
title='Test Post',
content='Test content',
author=self.user
)
self.assertEqual(post.title, 'Test Post')
self.assertEqual(post.author, self.user)
Use factories to create test data consistently:
# tests/factories.py
import factory
from django.contrib.auth.models import User
from myapp.models import BlogPost, Category, Tag
class UserFactory(factory.django.DjangoModelFactory):
"""Factory for creating User instances"""
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
class CategoryFactory(factory.django.DjangoModelFactory):
"""Factory for creating Category instances"""
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(factory.django.DjangoModelFactory):
"""Factory for creating Tag instances"""
class Meta:
model = Tag
name = factory.Faker('word')
slug = factory.LazyAttribute(lambda obj: obj.name.lower())
class BlogPostFactory(factory.django.DjangoModelFactory):
"""Factory for creating BlogPost instances"""
class Meta:
model = BlogPost
title = factory.Faker('sentence', nb_words=4)
slug = factory.LazyAttribute(lambda obj: obj.title.lower().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)
else:
# Create default tags
tag1 = TagFactory()
tag2 = TagFactory()
self.tags.add(tag1, tag2)
# Using factories in tests
class BlogPostModelTests(TestCase):
"""Test BlogPost model using factories"""
def test_post_creation_with_factory(self):
"""Test creating post using factory"""
post = BlogPostFactory()
self.assertIsNotNone(post.title)
self.assertIsNotNone(post.author)
self.assertIsNotNone(post.category)
self.assertEqual(post.tags.count(), 2)
def test_post_with_custom_data(self):
"""Test creating post with custom data"""
user = UserFactory(username='customuser')
category = CategoryFactory(name='Technology')
tags = [TagFactory(name='Python'), TagFactory(name='Django')]
post = BlogPostFactory(
title='Custom Post Title',
author=user,
category=category,
tags=tags
)
self.assertEqual(post.title, 'Custom Post Title')
self.assertEqual(post.author.username, 'customuser')
self.assertEqual(post.category.name, 'Technology')
self.assertEqual(post.tags.count(), 2)
def test_batch_creation(self):
"""Test creating multiple posts"""
posts = BlogPostFactory.create_batch(5)
self.assertEqual(len(posts), 5)
self.assertEqual(BlogPost.objects.count(), 5)
Follow the Arrange-Act-Assert pattern:
class BlogPostViewTests(TestCase):
"""Test BlogPost views"""
def test_post_detail_view(self):
"""Test blog post detail view displays correctly"""
# Arrange - Set up test data
user = UserFactory()
post = BlogPostFactory(
title='Test Post',
author=user,
status='published'
)
# Act - Perform the action being tested
response = self.client.get(f'/blog/{post.slug}/')
# Assert - Verify the results
self.assertEqual(response.status_code, 200)
self.assertContains(response, post.title)
self.assertContains(response, post.content)
self.assertContains(response, user.get_full_name())
self.assertTemplateUsed(response, 'blog/post_detail.html')
def test_post_list_view_pagination(self):
"""Test blog post list view pagination"""
# Arrange - Create multiple posts
posts = BlogPostFactory.create_batch(25, status='published')
# Act - Get first page
response = self.client.get('/blog/')
# Assert - Verify pagination
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Page 1 of')
self.assertEqual(len(response.context['posts']), 20) # 20 per page
# Act - Get second page
response = self.client.get('/blog/?page=2')
# Assert - Verify second page
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.context['posts']), 5) # Remaining posts
Always test boundary conditions and error cases:
class BlogPostModelTests(TestCase):
"""Test BlogPost model edge cases"""
def test_post_title_max_length(self):
"""Test post title maximum length validation"""
# Test valid length
valid_title = 'A' * 200 # Assuming max_length=200
post = BlogPostFactory(title=valid_title)
self.assertEqual(len(post.title), 200)
# Test invalid length
with self.assertRaises(ValidationError):
invalid_title = 'A' * 201
post = BlogPost(
title=invalid_title,
content='Test content',
author=UserFactory()
)
post.full_clean() # Trigger validation
def test_post_slug_uniqueness(self):
"""Test that post slugs must be unique"""
# Create first post
post1 = BlogPostFactory(title='Test Post')
# Try to create second post with same slug
with self.assertRaises(IntegrityError):
BlogPost.objects.create(
title='Test Post', # Same title = same slug
content='Different content',
author=UserFactory(),
slug=post1.slug # Explicitly set same slug
)
def test_post_without_author_fails(self):
"""Test that post creation fails without author"""
with self.assertRaises(IntegrityError):
BlogPost.objects.create(
title='Test Post',
content='Test content'
# No author provided
)
def test_post_content_empty_string(self):
"""Test post with empty content"""
post = BlogPostFactory(content='')
self.assertEqual(post.content, '')
# Test that empty content is allowed but blank=False would prevent it
# This depends on your model field definition
def test_post_status_choices(self):
"""Test post status field choices"""
# Test valid status
post = BlogPostFactory(status='draft')
self.assertEqual(post.status, 'draft')
post.status = 'published'
post.save()
self.assertEqual(post.status, 'published')
# Test invalid status (if using choices)
with self.assertRaises(ValidationError):
post.status = 'invalid_status'
post.full_clean()
class BlogPostBusinessLogicTests(TestCase):
"""Test complex business logic"""
def test_post_publication_workflow(self):
"""Test complete post publication workflow"""
# Create draft post
author = UserFactory()
post = BlogPostFactory(
author=author,
status='draft'
)
# Verify initial state
self.assertEqual(post.status, 'draft')
self.assertIsNone(post.published_at)
self.assertFalse(post.is_published())
# Publish post
post.publish()
# Verify published state
self.assertEqual(post.status, 'published')
self.assertIsNotNone(post.published_at)
self.assertTrue(post.is_published())
# Test that published post appears in public queries
published_posts = BlogPost.objects.published()
self.assertIn(post, published_posts)
def test_post_view_count_increment(self):
"""Test post view count incrementation"""
post = BlogPostFactory(view_count=0)
# Simulate multiple views
for i in range(5):
post.increment_view_count()
post.refresh_from_db()
self.assertEqual(post.view_count, 5)
def test_post_reading_time_calculation(self):
"""Test reading time calculation"""
# Short post
short_content = 'Short post content.'
short_post = BlogPostFactory(content=short_content)
self.assertEqual(short_post.get_reading_time(), 1) # Minimum 1 minute
# Long post (assuming 200 words per minute)
long_content = ' '.join(['word'] * 400) # 400 words
long_post = BlogPostFactory(content=long_content)
self.assertEqual(long_post.get_reading_time(), 2) # 2 minutes
def test_related_posts_algorithm(self):
"""Test related posts recommendation algorithm"""
# Create posts with shared tags
tag1 = TagFactory(name='Python')
tag2 = TagFactory(name='Django')
tag3 = TagFactory(name='JavaScript')
main_post = BlogPostFactory(tags=[tag1, tag2])
related_post1 = BlogPostFactory(tags=[tag1, tag3]) # Shares Python
related_post2 = BlogPostFactory(tags=[tag2, tag3]) # Shares Django
unrelated_post = BlogPostFactory(tags=[tag3]) # No shared tags
# Get related posts
related_posts = main_post.get_related_posts()
# Verify algorithm results
self.assertIn(related_post1, related_posts)
self.assertIn(related_post2, related_posts)
self.assertNotIn(unrelated_post, related_posts)
self.assertNotIn(main_post, related_posts) # Shouldn't include itself
# Run all tests
python manage.py test
# Run tests for specific app
python manage.py test blog
# Run specific test class
python manage.py test blog.tests.BlogPostModelTests
# Run specific test method
python manage.py test blog.tests.BlogPostModelTests.test_post_creation
# Run tests matching pattern
python manage.py test blog.tests.*Model*
# Verbose output
python manage.py test --verbosity=2
# Keep test database (faster for repeated runs)
python manage.py test --keepdb
# Run tests in parallel (faster on multi-core systems)
python manage.py test --parallel
# Specify number of parallel processes
python manage.py test --parallel 4
# Run with different settings
python manage.py test --settings=myproject.settings.test
# Debug mode (stops on first failure)
python manage.py test --debug-mode
# Reverse test order (useful for finding test dependencies)
python manage.py test --reverse
# Run only failed tests from last run
python manage.py test --failfast
# Discover tests without running them
python manage.py test --dry-run
# Run tests with specific tags
python manage.py test --tag=slow
python manage.py test --tag=integration
# Exclude tests with specific tags
python manage.py test --exclude-tag=slow
# Run tests by pattern
python manage.py test --pattern="test_*.py"
from django.test import TestCase, tag
class BlogPostTests(TestCase):
"""Tagged test examples"""
@tag('fast')
def test_post_creation(self):
"""Fast unit test"""
post = BlogPostFactory()
self.assertIsNotNone(post.title)
@tag('slow', 'integration')
def test_post_search_integration(self):
"""Slow integration test"""
# Create many posts
BlogPostFactory.create_batch(100)
# Test search functionality
results = BlogPost.objects.search('test')
self.assertIsNotNone(results)
@tag('external')
def test_post_social_media_sharing(self):
"""Test that requires external API"""
post = BlogPostFactory()
# This would test actual social media API integration
# Skip in CI environment without API keys
pass
# Run only fast tests
# python manage.py test --tag=fast
# Run integration tests
# python manage.py test --tag=integration
# Exclude slow tests
# python manage.py test --exclude-tag=slow
# settings/test.py
from .base import *
import tempfile
# Database configuration
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
}
}
# Disable migrations for faster tests
class DisableMigrations:
def __contains__(self, item):
return True
def __getitem__(self, item):
return None
MIGRATION_MODULES = DisableMigrations()
# Media files for tests
MEDIA_ROOT = tempfile.mkdtemp()
# Email backend
EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend'
# Cache configuration
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
}
}
# Logging configuration
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {
'class': 'logging.StreamHandler',
},
},
'loggers': {
'django': {
'handlers': ['console'],
'level': 'WARNING',
},
},
}
# Password hashers (faster for tests)
PASSWORD_HASHERS = [
'django.contrib.auth.hashers.MD5PasswordHasher',
]
# Celery configuration for tests
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True
# Test-specific apps
INSTALLED_APPS += [
'django_nose', # Alternative test runner
]
# Test runner configuration
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
NOSE_ARGS = [
'--with-coverage',
'--cover-package=blog,accounts,api',
'--cover-html',
'--cover-html-dir=htmlcov',
]
# test_runner.py
from django.test.runner import DiscoverRunner
from django.conf import settings
import os
class CustomTestRunner(DiscoverRunner):
"""Custom test runner with additional features"""
def setup_test_environment(self, **kwargs):
"""Set up test environment"""
super().setup_test_environment(**kwargs)
# Set test-specific environment variables
os.environ['TESTING'] = 'True'
# Configure test-specific settings
settings.DEBUG = False
settings.TEMPLATE_DEBUG = False
# Disable external services
settings.SEND_EMAILS = False
settings.USE_EXTERNAL_API = False
def teardown_test_environment(self, **kwargs):
"""Clean up test environment"""
super().teardown_test_environment(**kwargs)
# Clean up environment variables
if 'TESTING' in os.environ:
del os.environ['TESTING']
def run_tests(self, test_labels, extra_tests=None, **kwargs):
"""Run tests with custom behavior"""
# Print test configuration
print(f"Running tests with database: {settings.DATABASES['default']['ENGINE']}")
print(f"Test labels: {test_labels or 'All tests'}")
# Run tests
result = super().run_tests(test_labels, extra_tests, **kwargs)
# Generate coverage report
if hasattr(self, 'coverage'):
print("\nGenerating coverage report...")
self.coverage.report()
return result
# settings.py
TEST_RUNNER = 'myproject.test_runner.CustomTestRunner'
# .github/workflows/tests.yml
name: Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.8, 3.9, '3.10', '3.11']
django-version: [3.2, 4.0, 4.1, 4.2]
services:
postgres:
image: postgres:13
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test_db
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:6
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install Django==${{ matrix.django-version }}
pip install -r requirements/test.txt
- name: Run migrations
run: |
python manage.py migrate --settings=myproject.settings.test
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test_db
- name: Run tests
run: |
coverage run --source='.' manage.py test --settings=myproject.settings.test
coverage xml
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test_db
REDIS_URL: redis://localhost:6379/0
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
flags: unittests
name: codecov-umbrella
# Fast test configuration
class FastTestCase(TestCase):
"""Base class for fast tests"""
@classmethod
def setUpClass(cls):
"""Set up class-level data (runs once per test class)"""
super().setUpClass()
# Create shared test data
cls.user = User.objects.create_user(
username='testuser',
password='testpass123'
)
def setUp(self):
"""Set up instance-level data (runs before each test)"""
# Only create data that changes between tests
pass
# Use transactions for faster database tests
from django.test import TransactionTestCase
from django.db import transaction
class OptimizedDatabaseTests(TransactionTestCase):
"""Optimized database tests"""
def test_bulk_operations(self):
"""Test bulk database operations"""
# Use bulk_create for faster inserts
posts = [
BlogPost(
title=f'Post {i}',
content=f'Content {i}',
author_id=1
)
for i in range(100)
]
BlogPost.objects.bulk_create(posts)
self.assertEqual(BlogPost.objects.count(), 100)
@transaction.atomic
def test_atomic_operations(self):
"""Test atomic database operations"""
# Group related operations in transaction
with transaction.atomic():
user = User.objects.create_user(username='testuser')
profile = UserProfile.objects.create(user=user)
BlogPost.objects.create(
title='Test Post',
author=user,
content='Test content'
)
# Run tests in parallel
python manage.py test --parallel
# Specify number of processes
python manage.py test --parallel 4
# For CI environments
python manage.py test --parallel auto
With a solid understanding of writing and running tests, you're ready to explore Django's specific testing tools and utilities. The next chapter will cover Django's built-in testing tools, including fixtures, mocking capabilities, and specialized testing utilities that make testing Django applications more efficient and effective.
Key takeaways from this chapter:
Introduction to Django Testing
Django's testing framework is built on Python's standard unittest module but provides additional functionality specifically designed for web applications. Understanding Django's testing architecture and capabilities is essential for writing effective tests that ensure your application's reliability and maintainability.
Test Tools
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.