Skip to content

Backend Testing Standards

smattymatty edited this page Aug 25, 2025 · 4 revisions

🧪 Backend Testing Standards

Quality code starts with quality tests. Every test we write ensures EduLite works for students everywhere.

🎯 Why Testing Matters

In a project serving students globally, testing ensures:

  • Reliability: Features work in all conditions
  • Performance: Code runs fast on slow networks
  • Maintainability: Changes don't break existing features
  • Confidence: Contributors can improve code safely
  • Documentation: Tests show how code should work

Our Goal: Write tests that catch issues before students experience them.


📋 Table of Contents


Test Organization

Modular Test Structure

Our test suite is organized for efficiency and clarity:

users/tests/
├── __init__.py
├── README.md                    # Test suite documentation
├── fixtures/                    # Shared test data generators
│   ├── test_data_generators.py # Realistic persona generators
│   ├── bulk_test_users.py      # Efficient bulk user creation
│   └── users.json              # JSON fixtures
├── logic/                      # Business logic tests
│   └── __init__.py
├── views/                      # API endpoint tests (one file per view)
│   ├── test_UserListView.py
│   ├── test_UserSearchView.py
│   ├── test_SendFriendRequestView.py
│   ├── test_AcceptFriendRequestView.py
│   ├── test_DeclineFriendRequestView.py
│   ├── test_PendingFriendRequestListView.py
│   ├── test_UserRegistrationView.py
│   ├── test_UserProfileRetrieveUpdateView.py
│   └── ... (15+ view test files)
├── models/                     # Model tests
│   ├── test_user_profile.py
│   ├── test_profile_friend_request.py
│   └── test_user_profile_privacy_settings.py
├── serializers/                # Serializer tests
│   ├── test_UserSerializer.py
│   ├── test_ProfileSerializer.py
│   ├── test_ProfileFriendRequestSerializer.py
│   └── test_UserProfilePrivacySettingsSerializer.py
├── permissions/                # Permission class tests
│   ├── test_IsAdminUserOrReadOnly.py
│   ├── test_IsFriendRequestReceiver.py
│   ├── test_IsFriendRequestReceiverOrSender.py
│   ├── test_IsProfileOwnerOrAdmin.py
│   ├── test_IsUserOwnerOrAdmin.py
│   └── test_permission_integration.py
├── management/                 # Management command tests
│   └── test_create_dummy_users.py
├── integration/                # End-to-end tests
│   └── test_authentication_flow.py
└── performance/                # Performance tests with Mercury
    ├── mercury_demo.py         # Mercury framework demonstration
    ├── test_user_search.py     # Search performance tests
    ├── test_user_profile.py    # Profile performance tests
    └── test_pending_friend_requests.py

Key Features of the New Test Suite

  1. Modular Organization: One file per view/model/serializer for easy navigation
  2. Realistic Test Personas: Global user personas representing EduLite's diverse user base
  3. Efficient Data Management: setUpTestData for one-time data creation
  4. Performance Integration: Built-in Mercury framework support
  5. Comprehensive Coverage: Tests for views, models, serializers, permissions, logic, and integration flows

Running Specific Test Modules

# Test only logic functions
python manage.py test users.tests.logic

# Test only API views
python manage.py test users.tests.views

# Test only models
python manage.py test users.tests.models

# Test only serializers
python manage.py test users.tests.serializers

# Test permissions
python manage.py test users.tests.permissions

# Test management commands
python manage.py test users.tests.management

# Test integration flows
python manage.py test users.tests.integration

# Test performance
python manage.py test users.tests.performance

# Run all tests for an app
python manage.py test users

# Test a specific view
python manage.py test users.tests.views.test_UserSearchView

# Test a specific model
python manage.py test users.tests.models.test_user_profile

Test Types

1. Unit Tests (Logic & Models)

Test individual functions and methods in isolation.

# users/tests/logic/test_user_search_logic.py

from django.test import TestCase
from ...logic.user_search_logic import validate_search_query

class ValidateSearchQueryTest(TestCase):
    """Test search query validation logic."""
    
    def test_valid_query(self):
        """Valid queries should return success."""
        success, query, error = validate_search_query("John", min_length=2)
        self.assertTrue(success)
        self.assertEqual(query, "john")  # Should be lowercased
        self.assertIsNone(error)
    
    def test_empty_query(self):
        """Empty queries should return error."""
        success, query, error = validate_search_query("", min_length=2)
        self.assertFalse(success)
        self.assertIsNotNone(error)
        self.assertIn("provide a search term", error.data["error"])
    
    def test_short_query(self):
        """Queries below min_length should fail."""
        success, query, error = validate_search_query("a", min_length=2)
        self.assertFalse(success)
        self.assertIn("at least 2 characters", error.data["error"])

2. API View Tests

Test HTTP endpoints and responses.

# users/tests/views/test_UserSearchView.py

from django.test import TestCase
from django.contrib.auth.models import User
from rest_framework.test import APIClient
from rest_framework import status

class UserSearchViewTest(TestCase):
    """Test user search API endpoint."""
    
    @classmethod
    def setUpTestData(cls):
        """Create test data once for all tests."""
        # Create test users
        cls.john = User.objects.create_user(
            username='john_doe',
            first_name='John',
            last_name='Doe'
        )
        cls.jane = User.objects.create_user(
            username='jane_smith',
            first_name='Jane',
            last_name='Smith'
        )
    
    def setUp(self):
        """Set up test client for each test."""
        self.client = APIClient()
        self.client.force_authenticate(user=self.john)
    
    def test_search_by_username(self):
        """Should find users by username."""
        response = self.client.get('/api/users/search/?q=jane')
        
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(len(response.data['results']), 1)
        self.assertEqual(response.data['results'][0]['username'], 'jane_smith')
    
    def test_search_requires_authentication(self):
        """Unauthenticated requests should be allowed but limited."""
        self.client.logout()
        response = self.client.get('/api/users/search/?q=john')
        
        # Anonymous users can search but see limited results
        self.assertEqual(response.status_code, status.HTTP_200_OK)
    
    def test_search_pagination(self):
        """Results should be paginated."""
        # Create many users
        for i in range(25):
            User.objects.create_user(username=f'user{i}')
        
        response = self.client.get('/api/users/search/?q=user')
        
        self.assertIn('count', response.data)
        self.assertIn('next', response.data)
        self.assertIn('results', response.data)
        self.assertEqual(len(response.data['results']), 10)  # Default page size

3. Serializer Tests

Test data serialization and validation.

# users/tests/serializers/test_UserSerializer.py

from django.test import TestCase
from django.contrib.auth.models import User
from rest_framework.test import APIRequestFactory
from ...serializers import UserSerializer

class UserSerializerTest(TestCase):
    """Test user serialization."""
    
    def setUp(self):
        self.factory = APIRequestFactory()
        self.user = User.objects.create_user(
            username='testuser',
            email='test@example.com'
        )
    
    def test_serializer_contains_expected_fields(self):
        """Serializer should include all expected fields."""
        request = self.factory.get('/')
        request.user = self.user
        
        serializer = UserSerializer(
            self.user, 
            context={'request': request}
        )
        
        data = serializer.data
        self.assertIn('id', data)
        self.assertIn('username', data)
        self.assertIn('email', data)
        self.assertIn('profile_url', data)
    
    def test_serializer_excludes_password(self):
        """Password should never be serialized."""
        serializer = UserSerializer(self.user)
        self.assertNotIn('password', serializer.data)

4. Integration Tests

Test complete user flows across multiple components.

# users/tests/integration/test_registration_flow.py

from django.test import TransactionTestCase
from rest_framework.test import APIClient

class RegistrationFlowTest(TransactionTestCase):
    """Test complete user registration flow."""
    
    def setUp(self):
        self.client = APIClient()
    
    def test_complete_registration_flow(self):
        """User should be able to register, login, and update profile."""
        
        # 1. Register new user
        registration_data = {
            'username': 'newstudent',
            'email': 'student@test.com',
            'password': 'SecurePass123!',
            'password_confirm': 'SecurePass123!'
        }
        
        response = self.client.post(
            '/api/users/register/',
            data=registration_data,
            format='json'
        )
        self.assertEqual(response.status_code, 201)
        user_id = response.data['user_id']
        
        # 2. Login with new credentials
        login_response = self.client.post(
            '/api/auth/login/',
            data={
                'username': 'newstudent',
                'password': 'SecurePass123!'
            }
        )
        self.assertEqual(login_response.status_code, 200)
        self.assertIn('access', login_response.data)
        
        # 3. Update profile
        self.client.credentials(
            HTTP_AUTHORIZATION=f'Bearer {login_response.data["access"]}'
        )
        
        profile_response = self.client.patch(
            f'/api/users/{user_id}/profile/',
            data={'bio': 'I love learning!'}
        )
        self.assertEqual(profile_response.status_code, 200)
        self.assertEqual(profile_response.data['bio'], 'I love learning!')

Performance Testing

Mercury Framework Overview

https://github.com/80-20-Human-In-The-Loop/Django-Mercury-Performance-Testing

We use the Django Mercury Performance Testing Framework for identifying and preventing performance issues.

A note from the creator:

This library was created custom for EduLite after I saw a special need. This project needed a way to easily test performance, but it also needed a way to teach about Django performance best practices. If we truly want EduLite to be Open to all, I wanted to make sure that our performance testing tool could also teach, so we can have a variety of skill levels join our team, from beginner to expert! To an expert, this tool should be fast and useful, for beginners, it should guide you with the patience of a good teacher.

I wanted to turn the act of contributing to EduLite into an educational experience itself. Ideally, if a beginner comes to contribute on our project, they should learn A LOT, and feel like they have grown as a developer.

pip install django-mercury-performance==0.0.8

Two Testing Approaches

1. DjangoMercuryAPITestCase (Investigation Mode)

Use for discovering performance issues:

from django_mercury import DjangoMercuryAPITestCase

class UserSearchPerformanceInvestigation(DjangoMercuryAPITestCase):
    """
    Investigate performance issues with automatic monitoring.
    Mercury will detect N+1 queries, slow operations, and memory issues.
    """
    
    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        
        # Configure Mercury for investigation
        cls.configure_mercury(
            enabled=True,
            auto_scoring=True,              # Get performance grades
            verbose_reporting=True,         # See all issues
            educational_guidance=True,      # Get fix suggestions
        )
        
        # Set performance thresholds
        cls.set_performance_thresholds({
            'response_time_ms': 100,        # Flag if > 100ms
            'query_count_max': 10,          # Flag if > 10 queries
            'memory_overhead_mb': 30,       # Flag if > 30MB overhead
        })
    
    def test_search_with_many_results(self):
        """Test search performance with many users."""
        # Create 100 test users
        for i in range(100):
            User.objects.create_user(username=f'perf_user_{i}')
        
        # Mercury automatically monitors this request
        response = self.client.get('/api/users/search/?q=perf')
        
        # Mercury will report:
        # - Number of queries
        # - Response time
        # - Memory usage
        # - N+1 query detection
        # - Performance grade (A-F)

2. DjangoPerformanceAPITestCase (Assertion Mode)

Use for ongoing performance regression prevention:

from django_mercury import DjangoPerformanceAPITestCase, monitor_django_view

class UserSearchPerformanceTest(DjangoPerformanceAPITestCase):
    """
    Assert specific performance requirements.
    Tests fail if performance degrades.
    """
    
    def test_search_performance_requirements(self):
        """Search must meet performance requirements."""
        # Create test data
        for i in range(50):
            User.objects.create_user(username=f'user_{i}')
        
        # Monitor specific operation
        with monitor_django_view("user_search") as monitor:
            response = self.client.get('/api/users/search/?q=user')
        
        # Assert performance requirements
        self.assertResponseTimeLess(monitor.metrics, 100, 
            "Search should complete within 100ms")
        
        self.assertQueriesLess(monitor.metrics, 5, 
            "Search should use 5 or fewer queries")
        
        self.assertMemoryLess(monitor.metrics, 150, 
            "Search should use less than 150MB memory")
        
        # Check for N+1 queries
        self.assertNoNPlusOneQueries(monitor.metrics,
            "Search should not have N+1 query problems")

Best Practice: Investigation → Assertion

  1. Start with DjangoMercuryAPITestCase to investigate
  2. Find and fix performance issues
  3. Convert to DjangoPerformanceAPITestCase to prevent regression

Writing Tests

Test Structure

class TestClassName(TestCase):
    """
    Clear description of what's being tested.
    
    Explain any important context or assumptions.
    """
    
    @classmethod
    def setUpTestData(cls):
        """
        Create data once for all tests in class.
        Use this for read-only data.
        """
        cls.user = User.objects.create_user('testuser')
    
    def setUp(self):
        """
        Run before each test method.
        Use for test-specific setup.
        """
        self.client = APIClient()
        self.client.force_authenticate(user=self.user)
    
    def test_descriptive_name(self):
        """
        Test specific behavior or requirement.
        
        Follow pattern: test_what_when_expected
        """
        # Arrange - Set up test conditions
        data = {'key': 'value'}
        
        # Act - Perform the action
        result = function_under_test(data)
        
        # Assert - Check the outcome
        self.assertEqual(result, expected_value)
    
    def tearDown(self):
        """
        Clean up after each test.
        Usually not needed with transactions.
        """
        pass

Naming Conventions

# Test files: test_[WhatIsBeingTested].py
test_UserProfile.py
test_UserListView.py
test_friend_request_logic.py

# Test classes: [WhatIsBeingTested]Test
class UserProfileTest(TestCase):
class SendFriendRequestViewTest(TestCase):

# Test methods: test_[what]_[when]_[expected]
def test_profile_creation_with_valid_data_succeeds(self):
def test_search_with_empty_query_returns_error(self):
def test_friend_request_to_self_is_rejected(self):

Fixtures and Test Data

Available Test Fixtures

The new test suite includes comprehensive fixtures for realistic test data:

Realistic Global Personas (test_data_generators.py):

  • Ahmad (Gaza, Palestine): CS student learning despite challenges
  • Marie (Syrian refugee in France): Learning to rebuild
  • Joy (Nigeria): First in family pursuing higher education
  • Elena (Rural Romania): One computer in village library
  • James (Indigenous Canada): Cree Nation, satellite internet
  • Fatima (Sudan): Medical student with daily power cuts
  • Miguel (Brazil favela): Sharing phone with siblings
  • Sophie (Homeless, Paris): Using library computers
  • Dmitri (Ukraine): Displaced from Mariupol
  • Maria (Mexico): 2-hour walk to internet café

Using Fixtures for Reusable Data

# users/tests/fixtures/test_data_generators.py

# Create realistic student personas
def create_students_bulk():
    """Create diverse student users representing global user base."""
    # Returns dict with keys: ahmad, marie, joy, elena, james, fatima, miguel, sophie, dmitri, maria
    return students

# Create teacher personas
def create_teachers_bulk():
    """Create teacher users."""
    # Returns dict with keys: sarah, ahmed, okonkwo
    return teachers

# Set up relationships
def setup_friend_relationships(students, teachers):
    """Create friend relationships between users."""
    # Creates realistic friend networks

# Create test class
def create_test_class_with_students(teacher_username='test_teacher', num_students=10):
    """Create a teacher with a class of students."""
    return teacher, students

# Efficient bulk creation
from users.tests.fixtures.bulk_test_users import create_bulk_test_users

users = create_bulk_test_users(prefix='test', count=100)  # Efficient bulk creation

Using Fixtures in Tests

from ..fixtures.test_data_generators import create_students_bulk

class FriendshipTest(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.students = create_students_bulk(20)
        setup_friend_relationships(cls.students)

Assertions

Common Assertions

# Equality
self.assertEqual(actual, expected)
self.assertNotEqual(actual, expected)

# Truthiness
self.assertTrue(condition)
self.assertFalse(condition)

# Membership
self.assertIn(item, container)
self.assertNotIn(item, container)

# Exceptions
with self.assertRaises(ValidationError):
    invalid_operation()

# API responses
self.assertEqual(response.status_code, 200)
self.assertContains(response, "expected text")
self.assertJSONEqual(response.content, expected_json)

# Database
self.assertEqual(User.objects.count(), 5)
self.assertTrue(User.objects.filter(username='test').exists())

Custom Assertions (Mercury)

# Performance assertions
self.assertResponseTimeLess(metrics, 100, "Should be fast")
self.assertQueriesLess(metrics, 10, "Should minimize queries")
self.assertMemoryLess(metrics, 50, "Should use minimal memory")
self.assertNoNPlusOneQueries(metrics, "Should avoid N+1")

Running Tests

Basic Commands

# Run all tests
python manage.py test

# Run with verbosity
python manage.py test --verbosity=2

# Run specific app tests
python manage.py test users

# Run specific test file
python manage.py test users.tests.views.test_UserListView

# Run specific test method
python manage.py test users.tests.views.test_UserListView.TestClass.test_method

# Keep test database between runs (faster)
python manage.py test --keepdb

# Run tests in parallel
python manage.py test --parallel

# Run with coverage
coverage run --source='.' manage.py test
coverage report

Educational Mode (Mercury)

# Run with interactive educational feedback
python manage.py test --edu

# Mercury will:
# - Pause on failures
# - Explain what went wrong
# - Suggest fixes
# - Show performance metrics
# - Grade your code (A-F)

Continuous Integration

# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v2
    
    - name: Set up Python
      uses: actions/setup-python@v2
      with:
        python-version: 3.8
    
    - name: Install dependencies
      run: |
        pip install -r requirements.txt
    
    - name: Run tests
      run: |
        python manage.py test --parallel
    
    - name: Run performance tests
      run: |
        python manage.py test users.tests.performance

Test Coverage

Target Coverage

Module          | Target | Minimum
----------------|--------|--------
Views           | 95%    | 80%
Models          | 90%    | 75%
Serializers     | 90%    | 75%
Business Logic  | 100%   | 90%
Utilities       | 100%   | 90%

Measuring Coverage

# Install coverage
pip install coverage

# Run tests with coverage
coverage run --source='.' manage.py test

# Generate report
coverage report -m

# Generate HTML report
coverage html
# Open htmlcov/index.html in browser

# Check specific module
coverage report -m users/views.py

Coverage Configuration

# .coveragerc
[run]
source = .
omit = 
    */migrations/*
    */tests/*
    */venv/*
    manage.py
    */settings/*
    */wsgi.py
    */asgi.py

[report]
precision = 2
show_missing = True
skip_covered = False

[html]
directory = htmlcov

Best Practices

1. Test Isolation

# ❌ BAD: Tests depend on each other
class BadTest(TestCase):
    def test_1_create_user(self):
        self.user = User.objects.create_user('test')
    
    def test_2_use_user(self):
        # This fails if test_1 doesn't run first!
        self.user.profile.bio = "Test"

# ✅ GOOD: Each test is independent
class GoodTest(TestCase):
    def setUp(self):
        self.user = User.objects.create_user('test')
    
    def test_update_bio(self):
        self.user.profile.bio = "Test"
        self.user.profile.save()
        self.assertEqual(self.user.profile.bio, "Test")

2. Use Appropriate Test Types

# Use TestCase for database tests
class ModelTest(TestCase):
    """Tests that need database."""
    pass

# Use SimpleTestCase for non-database tests
class UtilityTest(SimpleTestCase):
    """Tests without database access."""
    pass

# Use TransactionTestCase for transaction tests
class TransactionTest(TransactionTestCase):
    """Tests that need real transactions."""
    pass

3. Test Data Patterns

# ❌ BAD: Hardcoded test data everywhere
def test_something(self):
    user = User.objects.create_user(
        username='john',
        email='john@example.com',
        first_name='John',
        last_name='Doe'
    )

# ✅ GOOD: Reusable factories
from ..fixtures import create_test_user

def test_something(self):
    user = create_test_user(username='john')

4. Mock External Services

from unittest.mock import patch, Mock

class EmailTest(TestCase):
    @patch('users.utils.send_email')
    def test_welcome_email_sent(self, mock_send):
        """Test that welcome email is sent on registration."""
        # Configure mock
        mock_send.return_value = True
        
        # Register user
        response = self.client.post('/api/register/', {...})
        
        # Verify email was "sent"
        mock_send.assert_called_once()
        self.assertIn('welcome', mock_send.call_args[0][0])

5. Test Error Cases

def test_invalid_input_handling(self):
    """Always test error cases."""
    
    # Test with missing data
    response = self.client.post('/api/users/', {})
    self.assertEqual(response.status_code, 400)
    
    # Test with invalid data
    response = self.client.post('/api/users/', {
        'username': 'a',  # Too short
        'email': 'not-an-email'
    })
    self.assertEqual(response.status_code, 400)
    self.assertIn('username', response.data)
    self.assertIn('email', response.data)

Common Testing Patterns

Testing Permissions

class PermissionTest(TestCase):
    """Test that permissions are properly enforced."""
    
    def test_owner_can_edit_profile(self):
        """Profile owner should be able to edit."""
        self.client.force_authenticate(user=self.owner)
        response = self.client.patch(
            f'/api/profiles/{self.profile.id}/',
            {'bio': 'Updated bio'}
        )
        self.assertEqual(response.status_code, 200)
    
    def test_other_user_cannot_edit_profile(self):
        """Non-owner should not be able to edit."""
        self.client.force_authenticate(user=self.other_user)
        response = self.client.patch(
            f'/api/profiles/{self.profile.id}/',
            {'bio': 'Hacked bio'}
        )
        self.assertEqual(response.status_code, 403)
    
    def test_unauthenticated_cannot_edit(self):
        """Unauthenticated user should not be able to edit."""
        self.client.logout()
        response = self.client.patch(
            f'/api/profiles/{self.profile.id}/',
            {'bio': 'Anonymous bio'}
        )
        self.assertEqual(response.status_code, 401)

Testing Pagination

class PaginationTest(TestCase):
    """Test API pagination."""
    
    @classmethod
    def setUpTestData(cls):
        # Create 25 users for pagination testing
        for i in range(25):
            User.objects.create_user(f'user_{i}')
    
    def test_default_page_size(self):
        """Should return 10 items by default."""
        response = self.client.get('/api/users/')
        self.assertEqual(len(response.data['results']), 10)
        self.assertEqual(response.data['count'], 25)
    
    def test_custom_page_size(self):
        """Should respect page_size parameter."""
        response = self.client.get('/api/users/?page_size=5')
        self.assertEqual(len(response.data['results']), 5)
    
    def test_pagination_links(self):
        """Should provide next/previous links."""
        response = self.client.get('/api/users/?page=2')
        self.assertIsNotNone(response.data['next'])
        self.assertIsNotNone(response.data['previous'])

Testing Filters

class FilterTest(TestCase):
    """Test API filtering."""
    
    def test_filter_by_status(self):
        """Should filter users by status."""
        User.objects.create_user('active_user').profile.status = 'active'
        User.objects.create_user('inactive_user').profile.status = 'inactive'
        
        response = self.client.get('/api/users/?status=active')
        
        self.assertEqual(len(response.data['results']), 1)
        self.assertEqual(
            response.data['results'][0]['username'], 
            'active_user'
        )

Summary

Following these testing standards ensures:

  1. Reliability - Code works correctly in all scenarios
  2. Performance - Fast response times on slow networks
  3. Maintainability - Safe refactoring with confidence
  4. Quality - Bugs caught before production
  5. Documentation - Tests show how code should work

Remember

  • Test behavior, not implementation
  • Each test should have one clear purpose
  • Performance tests prevent regression
  • Good tests are documentation
  • Test the sad path, not just happy path

These standards evolve with our project. Suggest improvements via pull request!

💚 Every test you write is a promise to students that EduLite will work when they need it most. Test with purpose, test with care.

Clone this wiki locally