django.moscow
УслугиЦеныПроцессОбучениеБлог
Связаться

django.moscow

Поддержка и развитие Django-сайтов с гарантиями. 15+ лет опыта в веб-разработке.

Услуги

  • Аудит и стабилизация
  • Поддержка и SLA
  • Оптимизация
  • Развитие и фичи

Информация

  • Цены
  • Процесс работы
  • Блог
  • Контакты
  • Реквизиты

Контакты

  • constantin@potapov.me
  • @potapov_me
Политика конфиденциальности•Согласие на обработку ПД

© 2025 django.moscow. Все права защищены.

Сделано с ❤️ в России • potapov.me

  1. /
  2. Блог
  3. /
  4. Тестирование Django: от юнитов до production
Тестирование
20 ноября 2025
61 мин

Тестирование Django: от юнитов до production

Тестирование Django: от юнитов до production

Тестирование Django: от юнитов до production

Хорошие тесты — это не просто страховка от регрессий, а инвестиция в скорость разработки, уверенность при рефакторинге и документация вашего кода. В этом руководстве разберем современные подходы к тестированию Django, от TDD до production monitoring.

Пирамида тестирования (правильная)

        /\
       /E2E\      ← 5-10% (медленные, хрупкие)
      /------\      Критические user flows
     /Integr.\   ← 20-30% (взаимодействие компонентов)
    /----------\    API endpoints, database
   /   Unit     \ ← 60-70% (быстрые, надежные)
  /--------------\  Бизнес-логика, валидация

Антипаттерн — перевернутая пирамида:

  /--------------\
   \   Unit     /  ← 10% тестов
    \----------/
     \Integr./    ← 20% тестов
      \------/
       \E2E/      ← 70% тестов (медленно, часто ломается!)
        \/

Золотое правило: Тестируйте поведение, а не имплементацию.


Test-Driven Development (TDD) на практике

Red-Green-Refactor цикл

1. RED — Напишите failing test

# tests/test_discount_calculator.py
import pytest
from decimal import Decimal
from shop.services import DiscountCalculator

def test_pro_user_gets_20_percent_discount():
    """Pro users should receive 20% discount."""
    calculator = DiscountCalculator(user_tier='pro')
    price = Decimal('1000')

    discounted = calculator.apply_discount(price)

    assert discounted == Decimal('800')  # 20% off

Запуск: pytest → ❌ FAIL (функция не существует)

2. GREEN — Напишите минимальный код для прохождения

# shop/services.py
from decimal import Decimal

class DiscountCalculator:
    def __init__(self, user_tier):
        self.user_tier = user_tier

    def apply_discount(self, price):
        if self.user_tier == 'pro':
            return price * Decimal('0.8')
        return price

Запуск: pytest → ✅ PASS

3. REFACTOR — Улучшите код

# shop/services.py
from decimal import Decimal
from typing import Dict

class DiscountCalculator:
    # Конфигурация вынесена наружу
    DISCOUNTS: Dict[str, Decimal] = {
        'free': Decimal('0'),
        'pro': Decimal('0.20'),
        'enterprise': Decimal('0.30'),
    }

    def __init__(self, user_tier: str):
        if user_tier not in self.DISCOUNTS:
            raise ValueError(f"Unknown tier: {user_tier}")
        self.user_tier = user_tier

    def apply_discount(self, price: Decimal) -> Decimal:
        discount_rate = self.DISCOUNTS[self.user_tier]
        return price * (Decimal('1') - discount_rate)

Добавить тесты для новых edge cases:

@pytest.mark.parametrize('user_tier,price,expected', [
    ('free', Decimal('1000'), Decimal('1000')),
    ('pro', Decimal('1000'), Decimal('800')),
    ('enterprise', Decimal('1000'), Decimal('700')),
    ('pro', Decimal('0'), Decimal('0')),  # Edge case: нулевая цена
])
def test_discount_calculation(user_tier, price, expected):
    calculator = DiscountCalculator(user_tier=user_tier)
    assert calculator.apply_discount(price) == expected


def test_invalid_tier_raises_error():
    """Unknown tier should raise ValueError."""
    with pytest.raises(ValueError, match="Unknown tier"):
        DiscountCalculator(user_tier='invalid')

Результат: Код протестирован, читабельный и расширяемый.


Unit Tests — изолированная логика

Тестирование чистых функций

# utils/validators.py
from typing import Optional
import re

def validate_phone_number(phone: str) -> Optional[str]:
    """
    Validate Russian phone number.
    Returns normalized format or None if invalid.
    """
    # Удаляем все кроме цифр
    digits = re.sub(r'\D', '', phone)

    # Проверяем формат
    if len(digits) == 11 and digits.startswith('7'):
        return f"+7 ({digits[1:4]}) {digits[4:7]}-{digits[7:9]}-{digits[9:11]}"
    elif len(digits) == 10 and digits.startswith('9'):
        return f"+7 ({digits[0:3]}) {digits[3:6]}-{digits[6:8]}-{digits[8:10]}"

    return None


# tests/test_validators.py
import pytest

@pytest.mark.parametrize('input_phone,expected', [
    # Валидные форматы
    ('+79991234567', '+7 (999) 123-45-67'),
    ('89991234567', '+7 (999) 123-45-67'),
    ('79991234567', '+7 (999) 123-45-67'),
    ('9991234567', '+7 (999) 123-45-67'),
    ('8 (999) 123-45-67', '+7 (999) 123-45-67'),
    ('+7 999 123 45 67', '+7 (999) 123-45-67'),

    # Невалидные
    ('123', None),
    ('', None),
    ('+1234567890', None),  # Не российский номер
])
def test_validate_phone_number(input_phone, expected):
    assert validate_phone_number(input_phone) == expected

Тестирование методов модели

# models.py
from django.db import models
from decimal import Decimal

class Order(models.Model):
    items = models.JSONField(default=list)
    status = models.CharField(max_length=20, default='pending')
    promo_code = models.CharField(max_length=50, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    def subtotal(self) -> Decimal:
        """Calculate subtotal without discount."""
        return sum(
            Decimal(str(item['price'])) * item['qty']
            for item in self.items
        )

    def discount_amount(self) -> Decimal:
        """Calculate discount based on promo code."""
        # Реальная логика с БД вынесена в отдельный сервис
        # Здесь только расчет
        if not self.promo_code:
            return Decimal('0')

        discount_rate = {
            'SAVE10': Decimal('0.10'),
            'SAVE20': Decimal('0.20'),
        }.get(self.promo_code, Decimal('0'))

        return self.subtotal() * discount_rate

    def total(self) -> Decimal:
        """Calculate final total."""
        return self.subtotal() - self.discount_amount()

    def can_cancel(self) -> bool:
        """Check if order can be cancelled."""
        return self.status in ['pending', 'processing']

    def can_refund(self) -> bool:
        """Check if order can be refunded."""
        from django.utils import timezone
        from datetime import timedelta

        if self.status != 'completed':
            return False

        # Можно вернуть в течение 14 дней
        deadline = self.created_at + timedelta(days=14)
        return timezone.now() <= deadline


# tests/test_models.py
from django.test import TestCase
from decimal import Decimal
from django.utils import timezone
from datetime import timedelta

class OrderModelTest(TestCase):
    """Test Order model business logic."""

    def test_subtotal_calculation(self):
        """Should calculate subtotal from items."""
        order = Order(items=[
            {'price': '100.00', 'qty': 2},
            {'price': '50.00', 'qty': 1},
        ])
        self.assertEqual(order.subtotal(), Decimal('250.00'))

    def test_subtotal_empty_items(self):
        """Empty items should return zero."""
        order = Order(items=[])
        self.assertEqual(order.subtotal(), Decimal('0'))

    def test_discount_with_promo_code(self):
        """Should apply discount for valid promo code."""
        order = Order(
            items=[{'price': '100', 'qty': 1}],
            promo_code='SAVE20'
        )
        self.assertEqual(order.discount_amount(), Decimal('20'))

    def test_discount_invalid_promo_code(self):
        """Invalid promo code should not apply discount."""
        order = Order(
            items=[{'price': '100', 'qty': 1}],
            promo_code='INVALID'
        )
        self.assertEqual(order.discount_amount(), Decimal('0'))

    def test_total_with_discount(self):
        """Total should be subtotal minus discount."""
        order = Order(
            items=[{'price': '100', 'qty': 2}],
            promo_code='SAVE10'
        )
        # 200 - (200 * 0.1) = 180
        self.assertEqual(order.total(), Decimal('180'))

    def test_can_cancel_pending_order(self):
        """Pending order can be cancelled."""
        order = Order(status='pending')
        self.assertTrue(order.can_cancel())

    def test_cannot_cancel_shipped_order(self):
        """Shipped order cannot be cancelled."""
        order = Order(status='shipped')
        self.assertFalse(order.can_cancel())

    def test_can_refund_recent_completed_order(self):
        """Completed order within 14 days can be refunded."""
        order = Order(
            status='completed',
            created_at=timezone.now() - timedelta(days=7)
        )
        self.assertTrue(order.can_refund())

    def test_cannot_refund_old_completed_order(self):
        """Completed order after 14 days cannot be refunded."""
        order = Order(
            status='completed',
            created_at=timezone.now() - timedelta(days=15)
        )
        self.assertFalse(order.can_refund())

    def test_cannot_refund_pending_order(self):
        """Non-completed order cannot be refunded."""
        order = Order(status='pending')
        self.assertFalse(order.can_refund())

Integration Tests — компоненты вместе

Тестирование Views с database

# views.py
from django.views.generic import ListView
from django.db.models import Q

class ProductListView(ListView):
    model = Product
    paginate_by = 20

    def get_queryset(self):
        qs = super().get_queryset().filter(is_active=True)

        # Поиск
        if query := self.request.GET.get('q'):
            qs = qs.filter(
                Q(name__icontains=query) |
                Q(description__icontains=query)
            )

        # Фильтр по категории
        if category := self.request.GET.get('category'):
            qs = qs.filter(category=category)

        # Сортировка
        sort = self.request.GET.get('sort', '-created_at')
        allowed_sorts = ['price', '-price', 'name', '-name', 'created_at', '-created_at']
        if sort in allowed_sorts:
            qs = qs.order_by(sort)

        return qs


# tests/test_views.py
from django.test import TestCase, Client
from django.urls import reverse
from shop.models import Product

class ProductListViewTest(TestCase):
    """Integration tests for Product listing."""

    @classmethod
    def setUpTestData(cls):
        """Create test data once for all tests in this class."""
        cls.products = [
            Product.objects.create(
                name='Laptop',
                price=1000,
                category='electronics',
                is_active=True
            ),
            Product.objects.create(
                name='Book',
                price=20,
                category='books',
                is_active=True
            ),
            Product.objects.create(
                name='Phone',
                price=500,
                category='electronics',
                is_active=True
            ),
            Product.objects.create(
                name='Inactive Product',
                price=100,
                category='electronics',
                is_active=False  # Не должен показываться
            ),
        ]

    def setUp(self):
        """Runs before each test method."""
        self.client = Client()
        self.url = reverse('product-list')

    def test_list_shows_only_active_products(self):
        """Should only display active products."""
        response = self.client.get(self.url)

        self.assertEqual(response.status_code, 200)
        self.assertEqual(len(response.context['object_list']), 3)
        # Проверяем что inactive продукт не показан
        self.assertNotContains(response, 'Inactive Product')

    def test_filter_by_category(self):
        """Should filter products by category."""
        response = self.client.get(self.url, {'category': 'electronics'})

        products = response.context['object_list']
        self.assertEqual(len(products), 2)
        self.assertIn(self.products[0], products)  # Laptop
        self.assertIn(self.products[2], products)  # Phone

    def test_search_by_name(self):
        """Should search in product name."""
        response = self.client.get(self.url, {'q': 'book'})

        products = response.context['object_list']
        self.assertEqual(len(products), 1)
        self.assertEqual(products[0].name, 'Book')

    def test_sort_by_price_ascending(self):
        """Should sort by price (low to high)."""
        response = self.client.get(self.url, {'sort': 'price'})

        products = list(response.context['object_list'])
        self.assertEqual(products[0].name, 'Book')  # $20
        self.assertEqual(products[1].name, 'Phone')  # $500
        self.assertEqual(products[2].name, 'Laptop')  # $1000

    def test_sort_by_price_descending(self):
        """Should sort by price (high to low)."""
        response = self.client.get(self.url, {'sort': '-price'})

        products = list(response.context['object_list'])
        self.assertEqual(products[0].name, 'Laptop')
        self.assertEqual(products[2].name, 'Book')

    def test_invalid_sort_parameter(self):
        """Invalid sort should be ignored (use default)."""
        response = self.client.get(self.url, {'sort': 'malicious_field'})

        # Должна использоваться сортировка по умолчанию
        self.assertEqual(response.status_code, 200)

    def test_pagination(self):
        """Should paginate results."""
        # Создать 25 продуктов (paginate_by=20)
        for i in range(25):
            Product.objects.create(
                name=f'Product {i}',
                price=100,
                category='test',
                is_active=True
            )

        response = self.client.get(self.url)

        self.assertTrue(response.context['is_paginated'])
        self.assertEqual(len(response.context['object_list']), 20)

        # Вторая страница
        response = self.client.get(self.url, {'page': 2})
        self.assertEqual(len(response.context['object_list']), 8)  # 3 + 25 - 20

Тестирование Forms с валидацией

# forms.py
from django import forms
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError

User = get_user_model()

class RegistrationForm(forms.Form):
    email = forms.EmailField()
    password = forms.CharField(min_length=8)
    password_confirm = forms.CharField()
    terms_accepted = forms.BooleanField(required=True)

    def clean_email(self):
        """Validate email is unique."""
        email = self.cleaned_data['email']

        if User.objects.filter(email=email).exists():
            raise ValidationError("Email already registered")

        # Блокировать временные email сервисы
        blocked_domains = ['tempmail.com', '10minutemail.com']
        domain = email.split('@')[1]
        if domain in blocked_domains:
            raise ValidationError("Temporary email addresses not allowed")

        return email

    def clean(self):
        """Cross-field validation."""
        cleaned_data = super().clean()
        password = cleaned_data.get('password')
        password_confirm = cleaned_data.get('password_confirm')

        if password and password_confirm:
            if password != password_confirm:
                raise ValidationError("Passwords do not match")

        return cleaned_data


# tests/test_forms.py
from django.test import TestCase

class RegistrationFormTest(TestCase):
    """Test user registration form validation."""

    def test_valid_form(self):
        """Valid data should pass."""
        form = RegistrationForm({
            'email': 'test@example.com',
            'password': 'SecurePass123',
            'password_confirm': 'SecurePass123',
            'terms_accepted': True,
        })
        self.assertTrue(form.is_valid())

    def test_password_too_short(self):
        """Short password should fail."""
        form = RegistrationForm({
            'email': 'test@example.com',
            'password': 'short',
            'password_confirm': 'short',
            'terms_accepted': True,
        })
        self.assertFalse(form.is_valid())
        self.assertIn('password', form.errors)

    def test_passwords_do_not_match(self):
        """Mismatched passwords should fail."""
        form = RegistrationForm({
            'email': 'test@example.com',
            'password': 'Password123',
            'password_confirm': 'DifferentPass123',
            'terms_accepted': True,
        })
        self.assertFalse(form.is_valid())
        self.assertIn('Passwords do not match', str(form.errors))

    def test_duplicate_email(self):
        """Existing email should fail."""
        User.objects.create_user('test@example.com', 'password')

        form = RegistrationForm({
            'email': 'test@example.com',
            'password': 'Password123',
            'password_confirm': 'Password123',
            'terms_accepted': True,
        })
        self.assertFalse(form.is_valid())
        self.assertIn('email', form.errors)

    def test_temporary_email_blocked(self):
        """Temporary email should be blocked."""
        form = RegistrationForm({
            'email': 'user@tempmail.com',
            'password': 'Password123',
            'password_confirm': 'Password123',
            'terms_accepted': True,
        })
        self.assertFalse(form.is_valid())
        self.assertIn('Temporary email', str(form.errors['email']))

    def test_terms_not_accepted(self):
        """Terms must be accepted."""
        form = RegistrationForm({
            'email': 'test@example.com',
            'password': 'Password123',
            'password_confirm': 'Password123',
            'terms_accepted': False,
        })
        self.assertFalse(form.is_valid())
        self.assertIn('terms_accepted', form.errors)

API Tests (Django REST Framework)

Комплексное тестирование ViewSet

# serializers.py
from rest_framework import serializers
from shop.models import Product

class ProductSerializer(serializers.ModelSerializer):
    class Meta:
        model = Product
        fields = ['id', 'name', 'price', 'category', 'is_active']
        read_only_fields = ['id']

    def validate_price(self, value):
        if value <= 0:
            raise serializers.ValidationError("Price must be positive")
        return value


# views.py
from rest_framework import viewsets, filters
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from rest_framework.decorators import action
from rest_framework.response import Response

class ProductViewSet(viewsets.ModelViewSet):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer
    permission_classes = [IsAuthenticatedOrReadOnly]
    filter_backends = [filters.SearchFilter, filters.OrderingFilter]
    search_fields = ['name', 'description']
    ordering_fields = ['price', 'created_at']

    def get_queryset(self):
        """Only show active products to anonymous users."""
        qs = super().get_queryset()
        if not self.request.user.is_staff:
            qs = qs.filter(is_active=True)
        return qs

    @action(detail=True, methods=['post'])
    def activate(self, request, pk=None):
        """Custom action to activate product."""
        product = self.get_object()
        product.is_active = True
        product.save()
        return Response({'status': 'activated'})


# tests/test_api.py
from rest_framework.test import APITestCase, APIClient
from rest_framework import status
from django.contrib.auth import get_user_model

User = get_user_model()

class ProductAPITest(APITestCase):
    """Integration tests for Product API."""

    def setUp(self):
        """Set up test client and users."""
        self.client = APIClient()
        self.user = User.objects.create_user(
            'user@example.com',
            'password123'
        )
        self.staff = User.objects.create_user(
            'staff@example.com',
            'password123',
            is_staff=True
        )

        # Test data
        self.active_product = Product.objects.create(
            name='Active Product',
            price=100,
            is_active=True
        )
        self.inactive_product = Product.objects.create(
            name='Inactive Product',
            price=200,
            is_active=False
        )

    def test_list_products_anonymous(self):
        """Anonymous users can list active products only."""
        response = self.client.get('/api/products/')

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(len(response.data), 1)
        self.assertEqual(response.data[0]['name'], 'Active Product')

    def test_list_products_staff(self):
        """Staff can see all products including inactive."""
        self.client.force_authenticate(user=self.staff)
        response = self.client.get('/api/products/')

        self.assertEqual(len(response.data), 2)

    def test_create_product_authenticated(self):
        """Authenticated users can create products."""
        self.client.force_authenticate(user=self.user)

        data = {
            'name': 'New Product',
            'price': 150,
            'category': 'electronics',
            'is_active': True
        }
        response = self.client.post('/api/products/', data)

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(Product.objects.count(), 3)
        self.assertEqual(response.data['name'], 'New Product')

    def test_create_product_invalid_price(self):
        """Negative price should fail validation."""
        self.client.force_authenticate(user=self.user)

        data = {
            'name': 'Invalid Product',
            'price': -100,
            'category': 'electronics'
        }
        response = self.client.post('/api/products/', data)

        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
        self.assertIn('price', response.data)

    def test_create_product_anonymous(self):
        """Anonymous users cannot create products."""
        data = {'name': 'Test', 'price': 100}
        response = self.client.post('/api/products/', data)

        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)

    def test_update_product_authenticated(self):
        """Authenticated users can update products."""
        self.client.force_authenticate(user=self.user)

        data = {'name': 'Updated Name', 'price': 120}
        response = self.client.patch(
            f'/api/products/{self.active_product.id}/',
            data
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.active_product.refresh_from_db()
        self.assertEqual(self.active_product.name, 'Updated Name')

    def test_delete_product_authenticated(self):
        """Authenticated users can delete products."""
        self.client.force_authenticate(user=self.user)

        response = self.client.delete(f'/api/products/{self.active_product.id}/')

        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
        self.assertEqual(Product.objects.count(), 1)

    def test_search_products(self):
        """Search should filter by name."""
        response = self.client.get('/api/products/', {'search': 'Active'})

        self.assertEqual(len(response.data), 1)
        self.assertEqual(response.data[0]['name'], 'Active Product')

    def test_ordering_products(self):
        """Should order by specified field."""
        Product.objects.create(name='Z Product', price=50, is_active=True)

        response = self.client.get('/api/products/', {'ordering': 'price'})

        prices = [item['price'] for item in response.data]
        self.assertEqual(prices, sorted(prices))

    def test_activate_product_custom_action(self):
        """Custom activate action should work."""
        self.client.force_authenticate(user=self.user)

        response = self.client.post(
            f'/api/products/{self.inactive_product.id}/activate/'
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.inactive_product.refresh_from_db()
        self.assertTrue(self.inactive_product.is_active)

Advanced Testing с Pytest

Мощные фикстуры

# conftest.py
import pytest
from rest_framework.test import APIClient
from django.contrib.auth import get_user_model

User = get_user_model()

# ============================================================================
# Database Fixtures
# ============================================================================

@pytest.fixture
def user(db):
    """Create a regular user."""
    return User.objects.create_user(
        email='user@example.com',
        password='testpass123'
    )

@pytest.fixture
def staff_user(db):
    """Create a staff user."""
    return User.objects.create_user(
        email='staff@example.com',
        password='testpass123',
        is_staff=True
    )

@pytest.fixture
def superuser(db):
    """Create a superuser."""
    return User.objects.create_superuser(
        email='admin@example.com',
        password='testpass123'
    )

# ============================================================================
# API Client Fixtures
# ============================================================================

@pytest.fixture
def api_client():
    """Unauthenticated API client."""
    return APIClient()

@pytest.fixture
def authenticated_client(user):
    """Authenticated API client."""
    client = APIClient()
    client.force_authenticate(user=user)
    return client

@pytest.fixture
def staff_client(staff_user):
    """Staff authenticated API client."""
    client = APIClient()
    client.force_authenticate(user=staff_user)
    return client

# ============================================================================
# Product Fixtures
# ============================================================================

@pytest.fixture
def product(db):
    """Create a single active product."""
    from shop.models import Product
    return Product.objects.create(
        name='Test Product',
        price=100,
        category='electronics',
        is_active=True
    )

@pytest.fixture
def products(db):
    """Create multiple products."""
    from shop.models import Product
    return Product.objects.bulk_create([
        Product(name=f'Product {i}', price=100+i*10, is_active=True)
        for i in range(5)
    ])

# ============================================================================
# Order Fixtures
# ============================================================================

@pytest.fixture
def order(user):
    """Create an order for a user."""
    from shop.models import Order
    return Order.objects.create(
        user=user,
        items=[
            {'name': 'Product 1', 'price': '100', 'qty': 2},
            {'name': 'Product 2', 'price': '50', 'qty': 1},
        ],
        status='pending'
    )

# ============================================================================
# Parametrized Fixtures
# ============================================================================

@pytest.fixture(params=['pending', 'processing', 'shipped', 'completed'])
def order_with_status(request, user):
    """Create order with different statuses (parametrized)."""
    from shop.models import Order
    return Order.objects.create(
        user=user,
        status=request.param
    )

Использование фикстур

# tests/test_with_fixtures.py
import pytest

@pytest.mark.django_db
def test_user_can_view_own_orders(authenticated_client, user, order):
    """User can view their own orders."""
    response = authenticated_client.get('/api/orders/')

    assert response.status_code == 200
    assert len(response.data) == 1
    assert response.data[0]['status'] == 'pending'


@pytest.mark.django_db
def test_order_total_calculation(order):
    """Order total should be calculated correctly."""
    # 2*100 + 1*50 = 250
    assert order.subtotal() == 250


@pytest.mark.django_db
def test_orders_in_different_statuses(order_with_status):
    """Test behavior with different order statuses (parametrized)."""
    # Этот тест запустится 4 раза (по одному для каждого статуса)
    assert order_with_status.status in ['pending', 'processing', 'shipped', 'completed']

Markers для организации тестов

# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = myproject.settings.test
python_files = tests.py test_*.py *_test.py
python_classes = Test*
python_functions = test_*

markers =
    unit: Unit tests (fast, no DB)
    integration: Integration tests (DB access)
    slow: Slow tests (API calls, complex operations)
    smoke: Smoke tests (critical paths)
    security: Security-related tests


# tests/test_with_markers.py
import pytest

@pytest.mark.unit
def test_pure_function():
    """Fast unit test without DB."""
    from utils import calculate_discount
    assert calculate_discount(100, 0.1) == 90


@pytest.mark.integration
@pytest.mark.django_db
def test_database_query():
    """Integration test with DB."""
    from shop.models import Product
    Product.objects.create(name='Test')
    assert Product.objects.count() == 1


@pytest.mark.slow
@pytest.mark.django_db
def test_complex_operation():
    """Slow test that takes time."""
    # ... сложная операция


@pytest.mark.smoke
@pytest.mark.django_db
def test_critical_flow():
    """Smoke test for critical path."""
    # ... проверка критического функционала

Запуск:

# Только unit тесты (быстрые)
pytest -m unit

# Только integration тесты
pytest -m integration

# Все кроме slow
pytest -m "not slow"

# Smoke tests для быстрой проверки
pytest -m smoke

# Несколько маркеров
pytest -m "integration and not slow"

Property-Based Testing с Hypothesis

Property-based testing — генерация тысяч рандомных тест-кейсов для проверки инвариантов.

pip install hypothesis

Пример: тестирование валидации

# tests/test_hypothesis.py
from hypothesis import given, strategies as st
import pytest

# ============================================================================
# Strategies
# ============================================================================

# Генерация валидных email адресов
valid_email = st.emails()

# Генерация телефонных номеров
phone_numbers = st.from_regex(r'^[789]\d{9}$', fullmatch=True)

# Генерация цен
positive_prices = st.decimals(
    min_value='0.01',
    max_value='999999.99',
    places=2
)

# Генерация пользовательских данных
user_data = st.fixed_dictionaries({
    'email': valid_email,
    'age': st.integers(min_value=18, max_value=120),
    'username': st.text(
        alphabet=st.characters(whitelist_categories=('Lu', 'Ll')),
        min_size=3,
        max_size=20
    ),
})


# ============================================================================
# Property Tests
# ============================================================================

@given(price=positive_prices, discount_rate=st.floats(min_value=0, max_value=1))
def test_discount_never_exceeds_price(price, discount_rate):
    """
    Инвариант: скидка никогда не может быть больше цены.
    """
    from decimal import Decimal
    discount = price * Decimal(str(discount_rate))
    final_price = price - discount

    assert final_price >= 0
    assert final_price <= price


@given(phone=phone_numbers)
def test_phone_validation_accepts_valid_formats(phone):
    """
    Любой телефон в валидном формате должен пройти валидацию.
    """
    from utils.validators import validate_phone_number
    result = validate_phone_number(phone)

    assert result is not None
    assert '+7' in result


@given(items=st.lists(
    st.fixed_dictionaries({
        'price': positive_prices,
        'qty': st.integers(min_value=1, max_value=100)
    }),
    min_size=1,
    max_size=50
))
def test_order_total_is_sum_of_items(items):
    """
    Инвариант: total всегда равен сумме всех items.
    """
    from shop.models import Order
    from decimal import Decimal

    # Преобразуем Decimal в строку для JSON
    json_items = [
        {'price': str(item['price']), 'qty': item['qty']}
        for item in items
    ]

    order = Order(items=json_items)
    expected_total = sum(
        Decimal(item['price']) * item['qty']
        for item in items
    )

    assert order.subtotal() == expected_total


@given(user_tier=st.sampled_from(['free', 'pro', 'enterprise']))
def test_discount_calculator_handles_all_tiers(user_tier):
    """
    Калькулятор должен корректно работать со всеми tier'ами.
    """
    from shop.services import DiscountCalculator
    from decimal import Decimal

    calculator = DiscountCalculator(user_tier=user_tier)
    result = calculator.apply_discount(Decimal('1000'))

    # Результат должен быть валидным Decimal
    assert isinstance(result, Decimal)
    # Результат не может быть отрицательным
    assert result >= 0
    # Результат не может быть больше исходной цены
    assert result <= 1000

Hypothesis автоматически:

  • Генерирует тысячи тест-кейсов
  • Находит минимальный failing example при ошибке
  • Сохраняет failing cases для regression testing

Mutation Testing

Mutation testing — проверка качества тестов путем внесения мутаций в код.

pip install mutpy

Пример проверки

# services.py
def calculate_total(items):
    """Calculate total price of items."""
    total = 0
    for item in items:
        total += item['price'] * item['qty']
    return total


# tests/test_services.py
def test_calculate_total():
    items = [
        {'price': 100, 'qty': 2},
        {'price': 50, 'qty': 1},
    ]
    assert calculate_total(items) == 250

Запуск mutation testing:

mut.py --target services --unit-test tests.test_services -m

Мутации которые могут быть сделаны:

  • total += ... → total -= ... (должно сломать тест)
  • item['price'] * item['qty'] → item['price'] + item['qty']
  • return total → return 0

Mutation Score: Процент убитых мутаций = качество тестов

Mutation Score: 85%
15 mutants killed
3 mutants survived  ← Плохо! Нужны доп. тесты

Пример surviving mutant:

# Мутация: изменен 0 на 1
total = 1  # было total = 0

Добавить тест для edge case:

def test_calculate_total_empty_items():
    """Empty items should return zero."""
    assert calculate_total([]) == 0  # Убьет мутацию!

Testing Strategies для различных сценариев

Тестирование транзакций

# services.py
from django.db import transaction

class OrderService:
    @transaction.atomic
    def create_order_with_payment(self, user, items, payment_method):
        """
        Create order and process payment atomically.
        If payment fails, order should not be created.
        """
        # Создать заказ
        order = Order.objects.create(user=user, items=items)

        # Обработать платеж
        payment = Payment.objects.create(
            order=order,
            amount=order.total(),
            method=payment_method
        )

        # Вызвать внешний API
        if not self.process_payment_gateway(payment):
            # Rollback транзакции при ошибке
            raise PaymentError("Payment failed")

        order.status = 'paid'
        order.save()

        return order


# tests/test_transactions.py
from django.test import TestCase, TransactionTestCase

class OrderServiceTransactionTest(TransactionTestCase):
    """Use TransactionTestCase for testing transactions."""

    def test_order_not_created_on_payment_failure(self):
        """Order should not be saved if payment fails."""
        from unittest.mock import patch

        user = User.objects.create_user('test@example.com')
        service = OrderService()

        # Mock payment gateway to fail
        with patch.object(service, 'process_payment_gateway', return_value=False):
            with self.assertRaises(PaymentError):
                service.create_order_with_payment(
                    user=user,
                    items=[{'price': '100', 'qty': 1}],
                    payment_method='card'
                )

        # Verify order was NOT created (transaction rolled back)
        self.assertEqual(Order.objects.count(), 0)
        self.assertEqual(Payment.objects.count(), 0)

    def test_order_created_on_payment_success(self):
        """Order should be saved if payment succeeds."""
        from unittest.mock import patch

        user = User.objects.create_user('test@example.com')
        service = OrderService()

        with patch.object(service, 'process_payment_gateway', return_value=True):
            order = service.create_order_with_payment(
                user=user,
                items=[{'price': '100', 'qty': 1}],
                payment_method='card'
            )

        self.assertEqual(order.status, 'paid')
        self.assertEqual(Order.objects.count(), 1)
        self.assertEqual(Payment.objects.count(), 1)

Тестирование Celery tasks

# tasks.py
from celery import shared_task

@shared_task
def send_order_confirmation_email(order_id):
    """Send order confirmation email asynchronously."""
    order = Order.objects.get(id=order_id)

    send_email(
        to=order.user.email,
        subject='Order Confirmation',
        body=f'Your order #{order.id} is confirmed.'
    )

    return True


# tests/test_tasks.py
import pytest
from unittest.mock import patch, Mock

@pytest.mark.django_db
@patch('tasks.send_email')
def test_send_order_confirmation_email(mock_send_email):
    """Task should send email with correct data."""
    from shop.models import Order
    from tasks import send_order_confirmation_email

    user = User.objects.create_user('test@example.com')
    order = Order.objects.create(user=user)

    # Execute task synchronously (не через Celery)
    send_order_confirmation_email(order.id)

    # Verify email was sent
    mock_send_email.assert_called_once()
    call_args = mock_send_email.call_args[1]
    assert call_args['to'] == 'test@example.com'
    assert f'#{order.id}' in call_args['body']


# Тестирование с реальным Celery (интеграция)
@pytest.mark.django_db
def test_send_order_confirmation_email_celery_integration():
    """Test actual Celery task execution."""
    from celery.result import AsyncResult
    from tasks import send_order_confirmation_email

    user = User.objects.create_user('test@example.com')
    order = Order.objects.create(user=user)

    # Отправить задачу в Celery
    result = send_order_confirmation_email.delay(order.id)

    # Подождать выполнения
    assert result.get(timeout=10) == True

Тестирование race conditions

# tests/test_concurrency.py
import pytest
from django.test import TransactionTestCase
from threading import Thread
from shop.models import Product

class ConcurrencyTest(TransactionTestCase):
    """Test concurrent operations."""

    def test_concurrent_stock_decrease(self):
        """
        Test that concurrent purchases correctly decrease stock.
        """
        product = Product.objects.create(name='Limited', price=100, stock=10)

        def buy_product():
            """Simulate purchase (decrease stock)."""
            p = Product.objects.select_for_update().get(id=product.id)
            if p.stock > 0:
                p.stock -= 1
                p.save()

        # Start 10 concurrent threads
        threads = [Thread(target=buy_product) for _ in range(10)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        # Stock should be 0 (all 10 purchases succeeded)
        product.refresh_from_db()
        self.assertEqual(product.stock, 0)


    def test_overselling_prevented(self):
        """
        Should not oversell (stock should never go negative).
        """
        product = Product.objects.create(name='Limited', price=100, stock=5)

        results = []

        def try_buy_product():
            try:
                p = Product.objects.select_for_update().get(id=product.id)
                if p.stock > 0:
                    p.stock -= 1
                    p.save()
                    results.append('success')
                else:
                    results.append('out_of_stock')
            except Exception as e:
                results.append(f'error: {e}')

        # Try to buy 10 items when only 5 in stock
        threads = [Thread(target=try_buy_product) for _ in range(10)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        product.refresh_from_db()

        # Only 5 should succeed
        self.assertEqual(results.count('success'), 5)
        self.assertEqual(results.count('out_of_stock'), 5)
        self.assertEqual(product.stock, 0)

Factory Boy — продвинутое использование

# factories.py
import factory
from factory.django import DjangoModelFactory
from factory import Faker, SubFactory, LazyAttribute, post_generation
from decimal import Decimal

class UserFactory(DjangoModelFactory):
    class Meta:
        model = User

    email = Faker('email')
    is_active = True

    @post_generation
    def password(self, create, extracted, **kwargs):
        if not create:
            return
        password = extracted or 'defaultpass123'
        self.set_password(password)


class ProductFactory(DjangoModelFactory):
    class Meta:
        model = Product

    name = Faker('catch_phrase')
    price = Faker('pydecimal', left_digits=4, right_digits=2, positive=True)
    category = Faker('random_element', elements=['electronics', 'books', 'clothing'])
    is_active = True

    # Зависимость от категории
    @factory.lazy_attribute
    def description(self):
        return f"A great {self.category} product"


class OrderFactory(DjangoModelFactory):
    class Meta:
        model = Order

    user = SubFactory(UserFactory)
    status = Faker('random_element', elements=['pending', 'processing', 'shipped'])

    # Динамическая генерация items
    @factory.lazy_attribute
    def items(self):
        num_items = factory.Faker('random_int', min=1, max=5).generate({})
        return [
            {
                'name': factory.Faker('word').generate({}),
                'price': str(factory.Faker('random_int', min=10, max=1000).generate({})),
                'qty': factory.Faker('random_int', min=1, max=5).generate({})
            }
            for _ in range(num_items)
        ]


# Использование в тестах
@pytest.mark.django_db
def test_order_with_factory():
    """Create realistic test data with Factory Boy."""

    # Создать заказ с автоматически сгенерированным пользователем и items
    order = OrderFactory()

    assert order.user.email
    assert len(order.items) > 0
    assert order.total() > 0


@pytest.mark.django_db
def test_multiple_orders_same_user():
    """Create multiple orders for same user."""

    user = UserFactory()

    # Создать 5 заказов для одного пользователя
    orders = OrderFactory.create_batch(5, user=user)

    assert all(o.user == user for o in orders)
    assert Order.objects.filter(user=user).count() == 5


@pytest.mark.django_db
def test_custom_attributes():
    """Override factory defaults."""

    # Переопределить конкретные поля
    product = ProductFactory(
        name='Custom Product',
        price=Decimal('99.99'),
        category='custom'
    )

    assert product.name == 'Custom Product'
    assert product.price == Decimal('99.99')

Coverage — правильное использование

Настройка .coveragerc

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

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

exclude_lines =
    # Standard pragma
    pragma: no cover

    # Debug code
    def __repr__
    def __str__

    # Abstract methods
    raise NotImplementedError
    raise NotImplemented
    return NotImplemented

    # Defensive programming
    raise AssertionError
    raise RuntimeError

    # Type checking
    if TYPE_CHECKING:
    if typing.TYPE_CHECKING:

    # Main block
    if __name__ == .__main__.:

[html]
directory = htmlcov

Запуск и анализ

# Запуск тестов с coverage
pytest --cov=. --cov-report=html --cov-report=term-missing

# Только показать summary
coverage report

# HTML отчет
coverage html
open htmlcov/index.html

# XML для CI/CD
coverage xml

Пример отчета:

Name                          Stmts   Miss  Cover   Missing
-----------------------------------------------------------
shop/models.py                   78      5    94%   45-47, 89-90
shop/views.py                   102     18    82%   12-15, 34-39, 67-73
shop/services.py                  45      0   100%
shop/forms.py                     34      8    76%   23-28, 45-46
-----------------------------------------------------------
TOTAL                           259     31    88%

Интерпретация:

  • Missing: Конкретные строки без покрытия
  • Cover: Процент покрытых строк
  • Цель: 80-90% для критичного кода (не 100%!)

Coverage в CI/CD

# .github/workflows/test.yml
- name: Run tests with coverage
  run: |
    pytest --cov=. --cov-report=xml --cov-report=term-missing

- name: Upload coverage to Codecov
  uses: codecov/codecov-action@v3
  with:
    file: ./coverage.xml
    fail_ci_if_error: true

- name: Check coverage threshold
  run: |
    coverage report --fail-under=80

E2E Tests с Playwright

Настройка Playwright

pip install playwright pytest-playwright
playwright install
# conftest.py
import pytest
from pytest_playwright.pytest_playwright import browser_context_args

@pytest.fixture(scope='session')
def browser_context_args(browser_context_args):
    """Configure browser context."""
    return {
        **browser_context_args,
        'viewport': {'width': 1920, 'height': 1080},
        'locale': 'ru-RU',
        'timezone_id': 'Europe/Moscow',
    }

E2E тесты для критических flows

# tests/test_e2e.py
import pytest
from playwright.sync_api import Page, expect

@pytest.mark.e2e
def test_complete_checkout_flow(page: Page, live_server):
    """
    Test complete user journey from product page to order confirmation.
    """
    base_url = live_server.url

    # 1. Open product page
    page.goto(f"{base_url}/products/1/")
    expect(page.locator('h1')).to_be_visible()

    # 2. Add to cart
    page.click('button[data-action="add-to-cart"]')
    expect(page.locator('.cart-count')).to_have_text('1')

    # 3. Go to cart
    page.click('a[href="/cart/"]')
    expect(page).to_have_url(f"{base_url}/cart/")

    # 4. Proceed to checkout
    page.click('button:text("Checkout")')
    expect(page).to_have_url(f"{base_url}/checkout/")

    # 5. Fill shipping information
    page.fill('input[name="full_name"]', 'Test User')
    page.fill('input[name="email"]', 'test@example.com')
    page.fill('input[name="phone"]', '+79991234567')
    page.fill('input[name="address"]', 'Test Address 123')

    # 6. Select payment method
    page.click('input[value="card"]')

    # 7. Accept terms
    page.check('input[name="terms_accepted"]')

    # 8. Submit order
    page.click('button[type="submit"]')

    # 9. Verify order confirmation
    expect(page).to_have_url(f"{base_url}/orders/success/")
    expect(page.locator('.success-message')).to_contain_text('Order confirmed')

    # 10. Verify order in database
    from shop.models import Order
    assert Order.objects.filter(user__email='test@example.com').exists()


@pytest.mark.e2e
def test_user_registration_and_login(page: Page, live_server):
    """Test user registration and login flow."""
    base_url = live_server.url

    # Register
    page.goto(f"{base_url}/register/")
    page.fill('input[name="email"]', 'newuser@example.com')
    page.fill('input[name="password"]', 'SecurePass123')
    page.fill('input[name="password_confirm"]', 'SecurePass123')
    page.check('input[name="terms_accepted"]')
    page.click('button[type="submit"]')

    # Should redirect to dashboard
    expect(page).to_have_url(f"{base_url}/dashboard/")
    expect(page.locator('.welcome-message')).to_be_visible()

    # Logout
    page.click('a[href="/logout/"]')
    expect(page).to_have_url(f"{base_url}/")

    # Login again
    page.goto(f"{base_url}/login/")
    page.fill('input[name="email"]', 'newuser@example.com')
    page.fill('input[name="password"]', 'SecurePass123')
    page.click('button[type="submit"]')

    expect(page).to_have_url(f"{base_url}/dashboard/")


@pytest.mark.e2e
def test_search_functionality(page: Page, live_server):
    """Test product search."""
    page.goto(live_server.url)

    # Type in search box
    page.fill('input[name="q"]', 'laptop')
    page.press('input[name="q"]', 'Enter')

    # Verify search results
    expect(page.locator('.product-item')).to_have_count_greater_than(0)
    expect(page.locator('.search-query')).to_contain_text('laptop')


@pytest.mark.e2e
def test_responsive_navigation(page: Page, live_server):
    """Test mobile navigation."""
    # Set mobile viewport
    page.set_viewport_size({'width': 375, 'height': 667})

    page.goto(live_server.url)

    # Mobile menu should be hidden
    expect(page.locator('.mobile-menu')).not_to_be_visible()

    # Click hamburger
    page.click('.hamburger-icon')

    # Menu should appear
    expect(page.locator('.mobile-menu')).to_be_visible()

Visual Regression Testing

@pytest.mark.e2e
def test_homepage_visual_regression(page: Page, live_server):
    """Test visual appearance of homepage."""
    page.goto(live_server.url)

    # Take screenshot and compare
    page.screenshot(path='screenshots/homepage.png')

    # With playwright built-in comparison
    expect(page).to_have_screenshot('homepage.png')

CI/CD Integration

GitHub Actions (детально)

# .github/workflows/test.yml
name: Test Suite

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

env:
  PYTHON_VERSION: '3.11'

jobs:
  # ========================================================================
  # Lint & Format Check
  # ========================================================================
  lint:
    name: Lint Code
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: ${{ env.PYTHON_VERSION }}

      - name: Install linters
        run: |
          pip install black ruff mypy

      - name: Check code formatting
        run: black --check .

      - name: Lint with ruff
        run: ruff check .

      - name: Type check with mypy
        run: mypy .

  # ========================================================================
  # Unit & Integration Tests
  # ========================================================================
  test:
    name: Test (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }})
    runs-on: ubuntu-latest

    strategy:
      fail-fast: false
      matrix:
        python-version: ['3.10', '3.11', '3.12']
        django-version: ['4.2', '5.0']

    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_DB: test_db
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

      redis:
        image: redis:7-alpine
        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 }}
          cache: 'pip'

      - name: Install dependencies
        run: |
          pip install --upgrade pip setuptools wheel
          pip install Django==${{ matrix.django-version }}
          pip install -r requirements.txt
          pip install -r requirements-test.txt

      - name: Run migrations
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db
        run: |
          python manage.py migrate --noinput

      - name: Run tests with coverage
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db
          REDIS_URL: redis://localhost:6379/0
        run: |
          pytest \
            --cov=. \
            --cov-report=xml \
            --cov-report=term-missing \
            --cov-config=.coveragerc \
            -v \
            --tb=short \
            --maxfail=5

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          file: ./coverage.xml
          flags: unittests
          name: codecov-${{ matrix.python-version }}-${{ matrix.django-version }}

      - name: Check coverage threshold
        run: |
          coverage report --fail-under=80

  # ========================================================================
  # E2E Tests
  # ========================================================================
  e2e:
    name: E2E Tests
    runs-on: ubuntu-latest
    needs: [test]

    steps:
      - uses: actions/checkout@v3

      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: ${{ env.PYTHON_VERSION }}

      - name: Install dependencies
        run: |
          pip install -r requirements.txt
          pip install playwright pytest-playwright
          playwright install --with-deps chromium

      - name: Run E2E tests
        run: |
          pytest tests/e2e/ \
            --browser chromium \
            --headed=false \
            --video=retain-on-failure \
            --screenshot=only-on-failure

      - name: Upload test artifacts
        if: failure()
        uses: actions/upload-artifact@v3
        with:
          name: e2e-failures
          path: |
            test-results/
            screenshots/

  # ========================================================================
  # Security Scan
  # ========================================================================
  security:
    name: Security Scan
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Run safety check
        run: |
          pip install safety
          safety check --file requirements.txt

      - name: Run bandit security linter
        run: |
          pip install bandit
          bandit -r . -f json -o bandit-report.json

      - name: Upload security report
        uses: actions/upload-artifact@v3
        with:
          name: security-report
          path: bandit-report.json

Pre-commit hooks

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/psf/black
    rev: 23.12.0
    hooks:
      - id: black

  - repo: https://github.com/charliermarsh/ruff-pre-commit
    rev: v0.1.8
    hooks:
      - id: ruff
        args: [--fix]

  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.5.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-added-large-files
        args: ['--maxkb=1000']
      - id: check-merge-conflict
      - id: detect-private-key

  - repo: local
    hooks:
      - id: pytest-fast
        name: Run fast tests
        entry: pytest -m "unit and not slow"
        language: system
        pass_filenames: false
        always_run: true

      - id: mypy
        name: Type check with mypy
        entry: mypy
        language: system
        types: [python]
        require_serial: true
# Установка
pip install pre-commit
pre-commit install

# Запуск вручную
pre-commit run --all-files

# Обновление хуков
pre-commit autoupdate

Что тестировать — практические рекомендации

✅ ВСЕГДА тестировать

# 1. Бизнес-логика
def calculate_loyalty_points(order_total, user_tier):
    """Критическая бизнес-логика → ТЕСТ ОБЯЗАТЕЛЕН."""
    multipliers = {'free': 1, 'pro': 2, 'enterprise': 3}
    return int(order_total * multipliers[user_tier])

# 2. Валидация и edge cases
def validate_promo_code(code):
    """Валидация → ТЕСТ ОБЯЗАТЕЛЕН."""
    if not code or len(code) < 3:
        raise ValidationError("Invalid code")
    return code.upper()

# 3. Permissions и security
class ProductViewSet(viewsets.ModelViewSet):
    """Security-critical → ТЕСТ ОБЯЗАТЕЛЕН."""
    def get_queryset(self):
        if self.request.user.is_staff:
            return Product.objects.all()
        return Product.objects.filter(is_active=True)

# 4. API endpoints
@api_view(['POST'])
def create_order(request):
    """Public API → ТЕСТ ОБЯЗАТЕЛЕН."""
    pass

# 5. Сложная логика
def calculate_shipping_cost(order, destination):
    """Сложная логика → ТЕСТ ОБЯЗАТЕЛЕН."""
    pass

❌ НЕ тестировать

# 1. Django built-ins
user = User.objects.create(...)  # Django уже протестировал
product.save()  # Не нужно

# 2. Trivial code
class Product:
    @property
    def name_upper(self):
        return self.name.upper()  # Слишком просто

# 3. External libraries
response = requests.get(url)  # requests уже протестирован

# 4. Конфигурация
ALLOWED_HOSTS = ['example.com']  # Не логика

🤔 Тестировать по ситуации

# 1. Templates (если сложная логика)
{% if user.is_pro and product.on_sale %}
    # Если логика сложная → напишите тест
{% endif %}

# 2. Admin customizations
class ProductAdmin(admin.ModelAdmin):
    list_filter = ['category']  # Простое → не нужно
    # Но если кастомные фильтры → тест нужен

# 3. Management commands
# Если критичная логика → тест нужен

Troubleshooting Flaky Tests

Причины flaky тестов

1. Зависимость от времени

# ❌ Flaky
def test_order_creation_time():
    order = Order.objects.create()
    assert order.created_at == timezone.now()  # Может упасть!

# ✅ Stable
def test_order_creation_time():
    before = timezone.now()
    order = Order.objects.create()
    after = timezone.now()
    assert before <= order.created_at <= after

# ✅ Better with freezegun
from freezegun import freeze_time

@freeze_time('2024-01-01 12:00:00')
def test_order_creation_time():
    order = Order.objects.create()
    assert order.created_at == datetime(2024, 1, 1, 12, 0, 0)

2. Порядок выполнения тестов

# ❌ Зависит от порядка
class TestOrder:
    def test_create_order(self):
        Order.objects.create(id=1)

    def test_get_order(self):
        order = Order.objects.get(id=1)  # Упадет если запустится первым!

# ✅ Независимые тесты
class TestOrder:
    @pytest.fixture
    def order(self):
        return Order.objects.create()

    def test_create_order(self, order):
        assert order.id is not None

    def test_get_order(self, order):
        fetched = Order.objects.get(id=order.id)
        assert fetched == order

3. External dependencies

# ❌ Flaky (зависит от сети)
def test_fetch_exchange_rate():
    rate = fetch_from_external_api()
    assert rate > 0

# ✅ Stable (мокируем)
@patch('services.requests.get')
def test_fetch_exchange_rate(mock_get):
    mock_get.return_value.json.return_value = {'rate': 75.5}
    rate = fetch_from_external_api()
    assert rate == 75.5

Обнаружение flaky tests:

# Запустить тесты 100 раз
pytest --count=100 tests/test_order.py

# С pytest-flakefinder
pip install pytest-flakefinder
pytest --flake-finder --flake-runs=50

Production Testing Strategies

Smoke Tests после деплоя

# tests/smoke/test_critical_paths.py
import pytest
import requests

@pytest.mark.smoke
def test_homepage_loads(production_url):
    """Homepage should load successfully."""
    response = requests.get(production_url)
    assert response.status_code == 200
    assert 'Welcome' in response.text


@pytest.mark.smoke
def test_api_health_check(production_url):
    """API health endpoint should respond."""
    response = requests.get(f"{production_url}/api/health/")
    assert response.status_code == 200
    assert response.json()['status'] == 'healthy'


@pytest.mark.smoke
def test_database_connectivity(production_url):
    """Database should be accessible."""
    response = requests.get(f"{production_url}/api/health/detailed/")
    assert response.json()['database']['status'] == 'ok'


# Запуск после деплоя
# pytest tests/smoke/ --production-url=https://myapp.com

Canary Testing

# Постепенный роллаут с тестированием

# 1. Деплой на 5% трафика
# 2. Запустить smoke tests
# 3. Мониторить метрики
# 4. Если OK → 100% трафика

# Automated canary script
def run_canary_deployment():
    # Deploy to 5%
    deploy_canary(percentage=5)

    # Run smoke tests
    result = subprocess.run(['pytest', 'tests/smoke/', '-v'])
    if result.returncode != 0:
        rollback_canary()
        raise Exception("Smoke tests failed")

    # Check metrics
    error_rate = get_error_rate(minutes=5)
    if error_rate > 1.0:  # >1% errors
        rollback_canary()
        raise Exception(f"High error rate: {error_rate}%")

    # All good → full rollout
    deploy_full()

Заключение

Золотые правила тестирования

  1. Пирамида тестов: 70% unit, 20% integration, 10% E2E
  2. Тестируйте поведение, не имплементацию
  3. Каждый баг = новый тест (regression testing)
  4. Тесты должны быть быстрыми (<1 сек на 100 unit тестов)
  5. Независимые тесты (не зависят друг от друга)
  6. Читаемые тесты (тест = документация кода)
  7. 80%+ coverage — хорошая цель, но не самоцель

Практический план внедрения

Неделя 1: Foundation

  • Настроить pytest + coverage
  • Написать первые unit тесты для критичной логики
  • Настроить CI/CD

Неделя 2-3: Expand Coverage

  • Добавить integration тесты для API
  • Настроить Factory Boy
  • Довести coverage до 70%

Неделя 4: Advanced

  • E2E тесты для критичных flows
  • Property-based testing
  • Mutation testing

Ongoing:

  • Новая фича = новые тесты
  • Баг = regression test
  • Регулярный review flaky tests

Useful Resources

  • pytest: https://docs.pytest.org/
  • Factory Boy: https://factoryboy.readthedocs.io/
  • Hypothesis: https://hypothesis.readthedocs.io/
  • Playwright: https://playwright.dev/python/
  • Coverage.py: https://coverage.readthedocs.io/

Помните: Хорошие тесты экономят время в долгосрочной перспективе и дают уверенность при рефакторинге.

Нужна помощь с Django?

Берём проекты на поддержку с чётким SLA. Стабилизируем за 2 недели, даём план развития на 90 дней. 15+ лет опыта с Django.

Обсудить проектНаши услуги
Предыдущая статья
База данных

Безопасные миграции БД в Django без даунтайма

•