Unit-Tests ohne Framework: reine Python-Logik & Services testen
Ein Missverständnis, das mir oft begegnet: dass man in Django-Projekten alles mit dem Django-Testrunner testen müsse. Dabei sind die wertvollsten, schnellsten Tests genau die, die kein Framework brauchen — weil sie reine Logik prüfen.
Warum Tests ohne Framework?
Ein Test, der die Datenbank, das ORM oder den Test-Client hochfährt, ist um Größenordnungen langsamer als einer, der eine Funktion mit Eingabe und erwarteter Ausgabe prüft. Und er ist brüchiger. Deshalb: Kernlogik so schreiben, dass sie ohne Framework testbar ist.
Faustregel: Geschäftslogik gehört nicht in Views oder Models, sondern in reine Funktionen/Services. Dann lässt sie sich blitzschnell testen.
Mit unittest (Standardbibliothek)
# calc.py
def rabatt_berechnen(preis: float, prozent: float) -> float:
if not 0 <= prozent <= 100:
raise ValueError("Prozent muss zwischen 0 und 100 liegen")
return round(preis * (1 - prozent / 100), 2)# test_calc.py
import unittest
from calc import rabatt_berechnen
class TestRabatt(unittest.TestCase):
def test_normaler_rabatt(self):
self.assertEqual(rabatt_berechnen(100, 20), 80.0)
def test_kein_rabatt(self):
self.assertEqual(rabatt_berechnen(50, 0), 50.0)
def test_ungueltig(self):
with self.assertRaises(ValueError):
rabatt_berechnen(100, 150)
if __name__ == "__main__":
unittest.main()Ausführen mit python -m unittest — keine Abhängigkeiten, läuft überall.
Mit pytest (weniger Boilerplate)
pytest macht dasselbe deutlich knapper — einfache assert-Statements statt assertEqual:
# test_calc.py
import pytest
from calc import rabatt_berechnen
def test_normaler_rabatt():
assert rabatt_berechnen(100, 20) == 80.0
def test_kein_rabatt():
assert rabatt_berechnen(50, 0) == 50.0
def test_ungueltig():
with pytest.raises(ValueError, match="zwischen 0 und 100"):
rabatt_berechnen(100, 150)Service-Layer testen
In größeren Projekten kapsle ich Geschäftslogik in Service-Funktionen. Die bekommen ihre Abhängigkeiten übergeben (Dependency Injection) — so sind sie ohne Django testbar:
# services.py
def bestellung_gesamtpreis(positionen: list[dict], versand: float = 0) -> float:
"""Reine Funktion: keine DB, kein ORM, nur Logik."""
summe = sum(p["preis"] * p["menge"] for p in positionen)
return round(summe + versand, 2)def test_gesamtpreis():
positionen = [
{"preis": 10.0, "menge": 2},
{"preis": 5.5, "menge": 1},
]
assert bestellung_gesamtpreis(positionen, versand=4.99) == 30.49Logik von Django entkoppeln
Der Trick ist Architektur: Statt Logik im Model oder View, eine dünne Schicht drumherum. Das Model hält Daten, der Service rechnet, der View verknüpft:
# Statt: dicke Methode im Model (braucht DB zum Testen)
class Order(models.Model):
def calculate_total(self): ... # schwer isoliert testbar
# Besser: reine Funktion, Model ruft sie nur auf
from .services import bestellung_gesamtpreis
class Order(models.Model):
def calculate_total(self):
positionen = [{"preis": i.preis, "menge": i.menge} for i in self.items.all()]
return bestellung_gesamtpreis(positionen, self.versand)Parametrisierte Tests
Viele Fälle mit einer Testfunktion abdecken — ein pytest-Feature, das ich ständig nutze:
import pytest
@pytest.mark.parametrize("preis,prozent,erwartet", [
(100, 0, 100.0),
(100, 50, 50.0),
(100, 100, 0.0),
(99.99, 10, 89.99),
])
def test_rabatt_faelle(preis, prozent, erwartet):
assert rabatt_berechnen(preis, prozent) == erwartetFazit
Die schnellsten und stabilsten Tests prüfen reine Logik ohne Framework. Wer Geschäftslogik bewusst von Django entkoppelt, bekommt eine Testsuite, die in Millisekunden läuft und selten grundlos bricht. Framework-Tests (DB, Views) baut man dann gezielt obendrauf — nicht als Ersatz, sondern als Ergänzung.
