Testing

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.

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.

Test Structure and Organization

Test File Organization

# 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)

Test Factories

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)

Writing Effective Tests

Test Method Structure

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

Testing Edge Cases

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()

Testing Complex Business Logic

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

Running Tests

Basic Test Execution

# 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*

Test Execution Options

# 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

Test Discovery and Filtering

# 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"

Test Tagging

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

Test Configuration and Settings

Test-Specific Settings

# 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',
]

Custom Test Runner

# 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'

Continuous Integration

GitHub Actions Configuration

# .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

Test Performance Optimization

Speeding Up Tests

# 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'
            )

Parallel Test Execution

# 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

Next Steps

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:

  • Organize tests logically using packages and base classes
  • Use factories for consistent test data creation
  • Follow the Arrange-Act-Assert pattern for clear test structure
  • Test edge cases and error conditions thoroughly
  • Configure test settings for optimal performance
  • Use CI/CD for automated testing
  • Optimize test performance with parallel execution and efficient database usage