Django-Tests mit TDD
Testgetriebene Entwicklung für Django-Anwendungen mit pytest, factory_boy und Django REST Framework.
Wann aktivieren
- Schreiben neuer Django-Anwendungen
- Implementieren von Django REST Framework APIs
- Testen von Django-Modellen, Views und Serialsierern
- Einrichten der Testinfrastruktur für Django-Projekte
TDD-Workflow für Django
Rot-Grün-Refaktor-Zyklus
# Schritt 1: ROT - Fehlschlagenden Test schreiben
def test_user_creation():
user = User.objects.create_user(email='[email protected]', password='testpass123')
assert user.email == '[email protected]'
assert user.check_password('testpass123')
assert not user.is_staff
# Schritt 2: GRÜN - Test bestehen lassen
# User-Modell oder Factory erstellen
# Schritt 3: REFAKTOR - Verbessern, während Tests grün bleiben
Einrichtung
pytest-Konfiguration
# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = config.settings.test
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
--reuse-db
--nomigrations
--cov=apps
--cov-report=html
--cov-report=term-missing
--strict-markers
markers =
slow: mark tests as slow
integration: marks tests as integration tests
Testeinstellungen
# config/settings/test.py
from .base import *
DEBUG = True
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
}
}
# Migrationen für Geschwindigkeit deaktivieren
class DisableMigrations:
def __contains__(self, item):
return True
def __getitem__(self, item):
return None
MIGRATION_MODULES = DisableMigrations()
# Schnelleres Passwort-Hashing
PASSWORD_HASHERS = [
'django.contrib.auth.hashers.MD5PasswordHasher',
]
# E-Mail-Backend
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
# Celery immer eifrig
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True
conftest.py
# tests/conftest.py
import pytest
from django.utils import timezone
from django.contrib.auth import get_user_model
User = get_user_model()
@pytest.fixture(autouse=True)
def timezone_settings(settings):
"""Consistent timezone sicherstellen."""
settings.TIME_ZONE = 'UTC'
@pytest.fixture
def user(db):
"""Testbenutzer erstellen."""
return User.objects.create_user(
email='[email protected]',
password='testpass123',
username='testuser'
)
@pytest.fixture
def admin_user(db):
"""Admin-Benutzer erstellen."""
return User.objects.create_superuser(
email='[email protected]',
password='adminpass123',
username='admin'
)
@pytest.fixture
def authenticated_client(client, user):
"""Authentifizierten Client zurückgeben."""
client.force_login(user)
return client
@pytest.fixture
def api_client():
"""DRF API-Client zurückgeben."""
from rest_framework.test import APIClient
return APIClient()
@pytest.fixture
def authenticated_api_client(api_client, user):
"""Authentifizierten API-Client zurückgeben."""
api_client.force_authenticate(user=user)
return api_client
Factory Boy
Factory-Einrichtung
# tests/factories.py
import factory
from factory import fuzzy
from datetime import datetime, timedelta
from django.contrib.auth import get_user_model
from apps.products.models import Product, Category
User = get_user_model()
class UserFactory(factory.django.DjangoModelFactory):
"""Factory für User-Modell."""
class Meta:
model = User
email = factory.Sequence(lambda n: f"user{n}@example.com")
username = factory.Sequence(lambda n: f"user{n}")
password = factory.PostGenerationMethodCall('set_password', 'testpass123')
first_name = factory.Faker('first_name')
last_name = factory.Faker('last_name')
is_active = True
class CategoryFactory(factory.django.DjangoModelFactory):
"""Factory für Category-Modell."""
class Meta:
model = Category
name = factory.Faker('word')
slug = factory.LazyAttribute(lambda obj: obj.name.lower())
description = factory.Faker('text')
class ProductFactory(factory.django.DjangoModelFactory):
"""Factory für Product-Modell."""
class Meta:
model = Product
name = factory.Faker('sentence', nb_words=3)
slug = factory.LazyAttribute(lambda obj: obj.name.lower().replace(' ', '-'))
description = factory.Faker('text')
price = fuzzy.FuzzyDecimal(10.00, 1000.00, 2)
stock = fuzzy.FuzzyInteger(0, 100)
is_active = True
category = factory.SubFactory(CategoryFactory)
created_by = factory.SubFactory(UserFactory)
@factory.post_generation
def tags(self, create, extracted, **kwargs):
"""Tags zum Produkt hinzufügen."""
if not create:
return
if extracted:
for tag in extracted:
self.tags.add(tag)
Verwendung von Factories
# tests/test_models.py
import pytest
from tests.factories import ProductFactory, UserFactory
def test_product_creation():
"""Test zur Produkterstellung mit Factory."""
product = ProductFactory(price=100.00, stock=50)
assert product.price == 100.00
assert product.stock == 50
assert product.is_active is True
def test_product_with_tags():
"""Test für Produkt mit Tags."""
tags = [TagFactory(name='electronics'), TagFactory(name='new')]
product = ProductFactory(tags=tags)
assert product.tags.count() == 2
def test_multiple_products():
"""Test zum Erstellen mehrerer Produkte."""
products = ProductFactory.create_batch(10)
assert len(products) == 10
Modelltests
Modelltests
# tests/test_models.py
import pytest
from django.core.exceptions import ValidationError
from tests.factories import UserFactory, ProductFactory
class TestUserModel:
"""Test User-Modell."""
def test_create_user(self, db):
"""Test zum Erstellen eines regulären Benutzers."""
user = UserFactory(email='[email protected]')
assert user.email == '[email protected]'
assert user.check_password('testpass123')
assert not user.is_staff
assert not user.is_superuser
def test_create_superuser(self, db):
"""Test zum Erstellen eines Superusers."""
user = UserFactory(
email='[email protected]',
is_staff=True,
is_superuser=True
)
assert user.is_staff
assert user.is_superuser
def test_user_str(self, db):
"""Test der Zeichenfolgendarstellung des Benutzers."""
user = UserFactory(email='[email protected]')
assert str(user) == '[email protected]'
class TestProductModel:
"""Test Product-Modell."""
def test_product_creation(self, db):
"""Test zum Erstellen eines Produkts."""
product = ProductFactory()
assert product.id is not None
assert product.is_active is True
assert product.created_at is not None
def test_product_slug_generation(self, db):
"""Test der automatischen Slug-Generierung."""
product = ProductFactory(name='Test Product')
assert product.slug == 'test-product'
def test_product_price_validation(self, db):
"""Test, dass der Preis nicht negativ sein kann."""
product = ProductFactory(price=-10)
with pytest.raises(ValidationError):
product.full_clean()
def test_product_manager_active(self, db):
"""Test der aktiven Manager-Methode."""
ProductFactory.create_batch(5, is_active=True)
ProductFactory.create_batch(3, is_active=False)
active_count = Product.objects.active().count()
assert active_count == 5
def test_product_stock_management(self, db):
"""Test der Bestandsverwaltung."""
product = ProductFactory(stock=10)
product.reduce_stock(5)
product.refresh_from_db()
assert product.stock == 5
with pytest.raises(ValueError):
product.reduce_stock(10) # Nicht genügend Bestand
View-Tests
Django-View-Tests
# tests/test_views.py
import pytest
from django.urls import reverse
from tests.factories import ProductFactory, UserFactory
class TestProductViews:
"""Test Produkt-Views."""
def test_product_list(self, client, db):
"""Test der Produktliste-Ansicht."""
ProductFactory.create_batch(10)
response = client.get(reverse('products:list'))
assert response.status_code == 200
assert len(response.context['products']) == 10
def test_product_detail(self, client, db):
"""Test der Produktdetail-Ansicht."""
product = ProductFactory()
response = client.get(reverse('products:detail', kwargs={'slug': product.slug}))
assert response.status_code == 200
assert response.context['product'] == product
def test_product_create_requires_login(self, client, db):
"""Test, dass die Produkterstellung eine Authentifizierung erfordert."""
response = client.get(reverse('products:create'))
assert response.status_code == 302
assert response.url.startswith('/accounts/login/')
def test_product_create_authenticated(self, authenticated_client, db):
"""Test der Produkterstellung als authentifizierter Benutzer."""
response = authenticated_client.get(reverse('products:create'))
assert response.status_code == 200
def test_product_create_post(self, authenticated_client, db, category):
"""Test zum Erstellen eines Produkts per POST."""
data = {
'name': 'Test Product',
'description': 'A test product',
'price': '99.99',
'stock': 10,
'category': category.id,
}
response = authenticated_client.post(reverse('products:create'), data)
assert response.status_code == 302
assert Product.objects.filter(name='Test Product').exists()
DRF-API-Tests
Serializer-Tests
# tests/test_serializers.py
import pytest
from rest_framework.exceptions import ValidationError
from apps.products.serializers import ProductSerializer
from tests.factories import ProductFactory
class TestProductSerializer:
"""Test ProductSerializer."""
def test_serialize_product(self, db):
"""Test zum Serialisieren eines Produkts."""
product = ProductFactory()
serializer = ProductSerializer(product)
data = serializer.data
assert data['id'] == product.id
assert data['name'] == product.name
assert data['price'] == str(product.price)
def test_deserialize_product(self, db):
"""Test zum Deserialisieren von Produktdaten."""
data = {
'name': 'Test Product',
'description': 'Test description',
'price': '99.99',
'stock': 10,
'category': 1,
}
serializer = ProductSerializer(data=data)
assert serializer.is_valid()
product = serializer.save()
assert product.name == 'Test Product'
assert float(product.price) == 99.99
def test_price_validation(self, db):
"""Test der Preisvalidierung."""
data = {
'name': 'Test Product',
'price': '-10.00',
'stock': 10,
}
serializer = ProductSerializer(data=data)
assert not serializer.is_valid()
assert 'price' in serializer.errors
def test_stock_validation(self, db):
"""Test, dass der Bestand nicht negativ sein kann."""
data = {
'name': 'Test Product',
'price': '99.99',
'stock': -5,
}
serializer = ProductSerializer(data=data)
assert not serializer.is_valid()
assert 'stock' in serializer.errors
API-ViewSet-Tests
# tests/test_api.py
import pytest
from rest_framework.test import APIClient
from rest_framework import status
from django.urls import reverse
from tests.factories import ProductFactory, UserFactory
class TestProductAPI:
"""Test Product-API-Endpunkte."""
@pytest.fixture
def api_client(self):
"""API-Client zurückgeben."""
return APIClient()
def test_list_products(self, api_client, db):
"""Test zum Auflisten von Produkten."""
ProductFactory.create_batch(10)
url = reverse('api:product-list')
response = api_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert response.data['count'] == 10
def test_retrieve_product(self, api_client, db):
"""Test zum Abrufen eines Produkts."""
product = ProductFactory()
url = reverse('api:product-detail', kwargs={'pk': product.id})
response = api_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert response.data['id'] == product.id
def test_create_product_unauthorized(self, api_client, db):
"""Test zum Erstellen eines Produkts ohne Authentifizierung."""
url = reverse('api:product-list')
data = {'name': 'Test Product', 'price': '99.99'}
response = api_client.post(url, data)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_create_product_authorized(self, authenticated_api_client, db):
"""Test zum Erstellen eines Produkts als authentifizierter Benutzer."""
url = reverse('api:product-list')
data = {
'name': 'Test Product',
'description': 'Test',
'price': '99.99',
'stock': 10,
}
response = authenticated_api_client.post(url, data)
assert response.status_code == status.HTTP_201_CREATED
assert response.data['name'] == 'Test Product'
def test_update_product(self, authenticated_api_client, db):
"""Test zum Aktualisieren eines Produkts."""
product = ProductFactory(created_by=authenticated_api_client.user)
url = reverse('api:product-detail', kwargs={'pk': product.id})
data = {'name': 'Updated Product'}
response = authenticated_api_client.patch(url, data)
assert response.status_code == status.HTTP_200_OK
assert response.data['name'] == 'Updated Product'
Testabdeckung
coverage.py
# pytest mit coverage ausführen
pytest --cov=apps --cov-report=html --cov-report=term-missing
# Bestimmte Module testen
pytest tests/test_models.py --cov=apps.products --cov-report=term
# Mit Coverage-Konfiguration
# .coveragerc
[run]
source = apps
omit = */tests/*,*/migrations/*,*/.venv/*,*/admin.py,*/.cache/*
[report]
show_missing = True
skip_covered = True
exclude_lines =
pragma: no cover
def __repr__
if __name__ == .__main__.:
raise NotImplementedError
Mocking
Mocking in Django-Tests
# tests/test_mocks.py
import pytest
from unittest.mock import patch, MagicMock
from django.core.mail import send_mail
from apps.orders.services import process_payment
class TestPaymentProcessing:
"""Test Zahlungsabwicklung."""
@patch('apps.orders.services.stripe.Charge.create')
def test_process_payment_success(self, mock_stripe_charge, db):
"""Test erfolgreiche Zahlungsabwicklung."""
mock_stripe_charge.return_value = MagicMock(
id='ch_123',
amount=9999,
status='succeeded',
paid=True
)
result = process_payment(order_id=1, token='tok_visa')
assert result['success'] is True
assert result['charge_id'] == 'ch_123'
mock_stripe_charge.assert_called_once()
@patch('apps.orders.services.stripe.Charge.create')
def test_process_payment_failure(self, mock_stripe_charge, db):
"""Test fehlgeschlagene Zahlungsabwicklung."""
from stripe.error import CardError
mock_stripe_charge.side_effect = CardError(
message='Your card was declined.',
param='number',
code='card_declined'
)
result = process_payment(order_id=1, token='tok_chargeDeclined')
assert result['success'] is False
assert 'error' in result
Zusätzliche pytest-Fixtures
Datei-Upload-Tests
# tests/test_file_uploads.py
from io import BytesIO
from PIL import Image
from django.core.files.uploadedfile import SimpleUploadedFile
def create_test_image():
"""Testbild erstellen."""
image = Image.new('RGB', (100, 100), color='red')
buffer = BytesIO()
image.save(buffer, format='JPEG')
buffer.seek(0)
return SimpleUploadedFile(
'test.jpg',
buffer.read(),
content_type='image/jpeg'
)
@pytest.fixture
def test_image():
"""Testbild-Fixture."""
return create_test_image()
@pytest.fixture
def multiple_test_images():
"""Mehrere Testbilder erstellen."""
return [create_test_image() for _ in range(3)]
Zeitbezogene Tests
# tests/test_time_based.py
from datetime import datetime, timedelta
from django.utils import timezone
@pytest.fixture
def future_date():
"""Datum in der Zukunft erstellen."""
return timezone.now() + timedelta(days=30)
@pytest.fixture
def past_date():
"""Datum in der Vergangenheit erstellen."""
return timezone.now() - timedelta(days=30)
@pytest.fixture
def specific_date():
"""Spezifisches Datum zum Testen erstellen."""
return datetime(2024, 6, 15, tzinfo=timezone.utc)
Authentifizierungs-Fixtures
# tests/test_auth_fixtures.py
import pytest
from rest_framework.test import APIClient
from rest_framework.authtoken.models import Token
@pytest.fixture
def token_user(user):
"""Benutzer mit Token erstellen."""
token, _ = Token.objects.get_or_create(user=user)
return user, token
@pytest.fixture
def token_api_client(token_user):
"""API-Client mit Token-Authentifizierung erstellen."""
user, token = token_user
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f'Token {token.key}')
return client
Leistungsoptimierung
Datenbank-Fixtures
# tests/test_performance.py
import pytest
@pytest.fixture(scope='session')
def django_db_setup(django_db_setup, django_db_blocker):
"""Datenbank für Session-Scope-Fixtures einrichten."""
with django_db_blocker.unblock():
# Hier Datenbankvorgang einmal pro Test-Session durchführen
pass
@pytest.fixture(scope='class')
def shared_products(db):
"""Für alle Tests in einer Klasse freigegebene Produkte."""
return ProductFactory.create_batch(100)
Parallele Testausführung
pytest-xdist
# Tests parallel ausführen
pytest -n auto
# Bestimmte Anzahl Worker
pytest -n 4
# Mit Coverage und Parallelität
pytest -n 4 --cov=apps --cov-append
Fortgeschrittene Techniken
Parametrisierte Tests
@pytest.mark.parametrize('email,password,expected', [
('[email protected]', 'pass123', True),
('invalid', 'pass123', False),
('[email protected]', '', False),
])
def test_user_validation(email, password, expected):
"""Parametrisierter Test für Benutzervalidierung."""
from django.contrib.auth import get_user_model
User = get_user_model()
is_valid = email.count('@') > 0 and len(password) > 0
assert is_valid == expected
Benutzerdefinierte pytest-Marker
# markers in pytest.ini deklariert
@pytest.mark.slow
def test_heavy_computation():
"""Langsamer Test, der übersprungen werden kann."""
import time
time.sleep(10)
@pytest.mark.integration
def test_external_api():
"""Integrationstest für externe API."""
pass
Nützliche pytest-Plugins
| Plugin | Zweck |
|---|---|
| pytest-django | Django-Unterstützung für pytest |
| pytest-cov | Coverage-Berichterstattung |
| pytest-xdist | Parallele Testausführung |
| pytest-sugar | Verbesserte Ausgabeformatierung |
| pytest-mock | Mocking-Hilfsprogramme |
| pytest-watch | Automatische Testausführung bei Dateiänderungen |
Nützliche pytest-Befehle
# Grundlegende Testausführung
pytest
# Detaillierte Ausgabe
pytest -v
# Stopp beim ersten Fehler
pytest -x
# Bestimmten Test ausführen
pytest tests/test_models.py::TestUserModel::test_create_user
# Tests mit Markierungen ausführen
pytest -m "integration"
# Tests von Markierungen ausschließen
pytest -m "not slow"
# Letzte Fehler erneut ausführen
pytest --lf
# Fehlgeschlagene Tests zuerst ausführen
pytest --ff