
Хорошие тесты — это не просто страховка от регрессий, а инвестиция в скорость разработки, уверенность при рефакторинге и документация вашего кода. В этом руководстве разберем современные подходы к тестированию Django, от TDD до production monitoring.
/\
/E2E\ ← 5-10% (медленные, хрупкие)
/------\ Критические user flows
/Integr.\ ← 20-30% (взаимодействие компонентов)
/----------\ API endpoints, database
/ Unit \ ← 60-70% (быстрые, надежные)
/--------------\ Бизнес-логика, валидация
Антипаттерн — перевернутая пирамида:
/--------------\
\ Unit / ← 10% тестов
\----------/
\Integr./ ← 20% тестов
\------/
\E2E/ ← 70% тестов (медленно, часто ломается!)
\/
Золотое правило: Тестируйте поведение, а не имплементацию.
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')
Результат: Код протестирован, читабельный и расширяемый.
# 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())
# 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.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)
# 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)
# 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']
# 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 — генерация тысяч рандомных тест-кейсов для проверки инвариантов.
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 автоматически:
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 0Mutation 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 # Убьет мутацию!
# 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)
# 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
# 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)
# 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')
# .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%
Интерпретация:
# .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
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',
}
# 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()
@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')
# .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-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
# Если критичная логика → тест нужен
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
# 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
# Постепенный роллаут с тестированием
# 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: Foundation
Неделя 2-3: Expand Coverage
Неделя 4: Advanced
Ongoing:
Помните: Хорошие тесты экономят время в долгосрочной перспективе и дают уверенность при рефакторинге.
Берём проекты на поддержку с чётким SLA. Стабилизируем за 2 недели, даём план развития на 90 дней. 15+ лет опыта с Django.