Dutzende Backend-Tests in einem PR: Unsere Teststrategie für FastAPI
Die Beschreibung des Pull Requests lautete: „Testsuite hinzufügen – 52 Tests für Authentifizierung, Datenschicht und API-Schnittstellen.“ Er wurde drei Monate nach dem Produktivstart des FastAPI-Backends, das er abdeckte, integriert. So sollte das Testen nicht ablaufen, und es ist kein Anlass zum Feiern. Dennoch lohnt sich eine ehrliche Betrachtung: „Tests spät ergänzt“ ist eine wesentlich andere Situation als „Tests nie ergänzt“. Der Unterschied entscheidet darüber, ob sich eine Codebasis langfristig warten lässt oder irgendwann neu geschrieben werden muss.
Dieser Beitrag beschreibt die gewählte Testarchitektur, die konkreten Muster, die sich in einem echten FastAPI-Projekt bewährt haben, und den Inhalt des PRs jenseits der bloßen Anzahl der Tests.
Der Projektkontext
Das Backend ist ein FastAPI-Dienst für kundenbezogene Abläufe eines E-Commerce-Unternehmens: Auftragsverwaltung, Bestandsabfragen, Preisregeln und die Integration einer API eines externen Logistikdienstleisters. Die Datenbank ist PostgreSQL, angebunden über SQLAlchemy mit asynchroner Unterstützung. Die Authentifizierung basiert auf JWT; die Token werden durch einen separaten Authentifizierungsdienst validiert. Im normalen Betrieb werden drei externe APIs aufgerufen: die des Logistikdienstleisters, ein Währungsumrechnungsdienst und ein Produktdatenfeed.
Drei Monate Produktivbetrieb bedeuteten drei Monate Fehlerkorrekturen, neue Funktionen und schrittweise Änderungen – alles ohne Tests. Die Codebasis war nicht untestbar. Sie war auf Testbarkeit ausgelegt: injizierte Abhängigkeiten, kein globaler Zustand und Datenbanksitzungen, die pro Anfrage über das Dependency-Injection-System von FastAPI erstellt wurden. Die Tests waren schlicht nie geschrieben worden. Ein gezielter PR war das richtige Mittel – nicht, weil eine gebündelte Lieferung von Testabdeckung grundsätzlich empfehlenswert wäre, sondern weil „ein PR mit Tests“ geprüft, integriert und gepflegt wird. „Tests nach und nach ergänzen“ bleibt dagegen häufig auf unbestimmte Zeit im Backlog.
Die Fixture-Hierarchie
Das Fixture-System von pytest profitiert von bewusstem Design. Für ein FastAPI-Projekt mit einer echten Datenbank bietet sich eine Hierarchie mit drei Gültigkeitsbereichen an: Sitzung, Modul und Funktion; diese Testsuite nutzt hauptsächlich die Gültigkeitsbereiche Sitzung und Funktion.
Fixtures mit Sitzungsgültigkeit werden einmal pro Testlauf ausgeführt. Hierhin gehören das Erstellen der Datenbank, das Einrichten des Schemas (das folgende Beispiel verwendet Base.metadata.create_all; Alembic-Migrationen gehören an dieselbe Stelle) und das Befüllen der Tabellen mit Referenzdaten, die sich zwischen Tests nicht ändern. Alle Tests teilen sich die Datenbank-Engine auf Sitzungsebene. Dadurch fällt der Aufwand für das Erstellen des Schemas nur einmal an, nicht für jede Testdatei.
Fixtures mit Funktionsgültigkeit geben jedem Test einen eigenen, isolierten Zustand. Entscheidend ist die Fixture zum Zurückrollen von Transaktionen: Sie öffnet zu Beginn jedes Tests eine Transaktion und rollt sie am Ende zurück. Alle Schreibvorgänge des Tests – Benutzer anlegen, Bestellungen einfügen, Preise ändern – werden automatisch rückgängig gemacht. Die Tests bleiben isoliert, ohne dass zwischen den Läufen Tabellen geleert werden müssen.
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
@pytest_asyncio.fixture(scope="session")
async def engine():
engine = create_async_engine(settings.TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest_asyncio.fixture
async def db_session(engine):
"""Each test gets a transaction that rolls back on completion."""
async with engine.begin() as conn:
await conn.begin_nested()
session_factory = sessionmaker(
conn, class_=AsyncSession, expire_on_commit=False
)
async with session_factory() as session:
yield session
await session.rollback()
Der Aufruf begin_nested() erstellt einen Savepoint innerhalb der äußeren Transaktion. Beabsichtigt ist, dass Testcode innerhalb seiner Sitzung beliebig Commits ausführen kann, während das Zurückrollen der äußeren Transaktion beim Aufräumen alles rückgängig macht. Die gezeigte Fixture erreicht das nicht vollständig: engine.begin() führt beim normalen Verlassen seines Blocks einen Commit aus, und session.rollback() rollt nur die Änderungen der Sitzung selbst zurück. Für vollständige Isolation öffnen Sie die Verbindung mit engine.connect(), starten die äußere Transaktion mit conn.begin(), binden die Sitzung mit join_transaction_mode="create_savepoint" ein und rollen die äußere Transaktion nach dem Test ausdrücklich zurück. Mit dieser Konfiguration geben Aufrufe von session.commit() im Anwendungscode nur Savepoints frei, und das Zurückrollen der äußeren Transaktion macht diese Änderungen weiterhin rückgängig.
Externe APIs mocken
Drei externe APIs bedeuten drei mögliche Ursachen für instabile Tests, wenn diese tatsächlich auf das Netzwerk zugreifen. Die Standardlösung – den HTTP-Client zu mocken – lässt sich mit respx einfach umsetzen. Die Bibliothek fängt httpx-Anfragen auf der Transportschicht ab.
import respx
import httpx
import pytest
@pytest.mark.asyncio
async def test_shipping_estimate_returns_carrier_data(test_client, valid_token):
mock_response = {
"carrier": "DHL",
"estimated_days": 3,
"cost_eur": 8.50
}
with respx.mock:
respx.post("https://api.logistics-provider.com/v2/rates").mock(
return_value=httpx.Response(200, json=mock_response)
)
response = await test_client.post(
"/api/v1/shipping/estimate",
json={"destination_zip": "10115", "weight_kg": 2.5},
headers={"Authorization": f"Bearer {valid_token}"}
)
assert response.status_code == 200
assert response.json()["estimated_days"] == 3
Der Kontextmanager respx.mock leitet alle ausgehenden httpx-Anfragen durch die Mock-Schicht. Jede nicht ausdrücklich registrierte Anfrage löst einen Fehler aus – genau die richtige Voreinstellung. Tests, die versehentlich produktive APIs aufrufen, sind schwer zu erkennen und können teuer werden, wenn sie unerwünschte Änderungen auslösen.
Für den Währungsumrechnungsdienst, der bei jeder Bestellung in einer anderen Währung als EUR aufgerufen wird, stellt eine gemeinsame Fixture einen vorkonfigurierten respx-Router mit Standardwechselkursen bereit. Tests der Währungslogik überschreiben bestimmte Währungspaare. Tests anderer Abläufe, die nebenbei diese Abhängigkeit auslösen, verwenden die Standardwerte der Fixture, ohne sich damit befassen zu müssen. Diese Trennung hält die Testabsicht klar: Versandtests prüfen den Versand und nicht den zufällig konfigurierten Wechselkurs.
Authentifizierungsabläufe testen
Das Dependency-Injection-System von FastAPI ist eine seiner größten Stärken beim Testen. Die Authentifizierung ist in diesem Projekt eine Abhängigkeit: Jede geschützte Route deklariert current_user: User = Depends(get_current_user). Dabei validiert get_current_user ein JWT und gibt ein Benutzerobjekt zurück. In Tests wird diese Abhängigkeit auf Anwendungsebene ersetzt:
from app.main import app
from app.dependencies import get_current_user
from app.models import User
def make_user(role: str = "operator") -> User:
return User(id="test-user-id", email="[email protected]", role=role)
@pytest.fixture
def operator_client(db_session):
app.dependency_overrides[get_current_user] = lambda: make_user("operator")
client = TestClient(app)
yield client
app.dependency_overrides.clear()
@pytest.fixture
def admin_client(db_session):
app.dependency_overrides[get_current_user] = lambda: make_user("admin")
client = TestClient(app)
yield client
app.dependency_overrides.clear()
Separate Fixtures für unterschiedliche Rollen machen Tests der Berechtigungsgrenzen eindeutig. Ein Test, der eine 403-Antwort erwartet, wenn ein Bediener eine Administratoraktion ausführen möchte, liest sich ebenso klar wie die zugrunde liegende Geschäftsregel. Heißen die Fixtures operator_client und admin_client, ist die Absicht des Tests erkennbar, ohne seinen Inhalt lesen zu müssen.
Eine kleine Gruppe von Tests prüft den Codepfad zur JWT-Validierung direkt: abgelaufene Token, fehlerhaft aufgebaute Token und Token mit falschen Audience-Claims. Diese Tests verwenden die Fixture zum Ersetzen der Abhängigkeit nicht. Sie übergeben unveränderte Token über den Testclient und prüfen auf 401- oder 403-Antworten. Die Trennung ist bewusst gewählt: Die meisten Tests prüfen das Verhalten nach der Authentifizierung und nutzen deshalb die Ersetzung. Authentifizierungstests prüfen die Authentifizierungslogik selbst. Eine Vermischung verschleiert, was der jeweilige Test tatsächlich abdeckt.
Fehlerpfade bewusst testen
Tests des erfolgreichen Normalfalls sind leicht zu schreiben und unter Zeitdruck oft die einzigen, die entstehen. Fehler im Produktivbetrieb liegen selten im Normalfall. In dieser Codebasis betraf das folgenreichste ungetestete Verhalten die Fehlerbehandlung: Was passiert, wenn die Logistik-API 503 zurückgibt? Was antwortet die API, wenn Pflichtfelder in einer Anfrage fehlen? Was passiert, wenn eine gleichzeitige Änderung eine Datenbankbedingung verletzt?
Für jeden Mock einer externen API ergänzen wir neben dem Erfolgsfall mindestens einen Fehlerfall. Die Fixture der Logistik-API hat eine Variante, die 503 zurückgibt. Damit prüfen wir, dass der Endpunkt eine strukturierte Fehlerantwort mit passendem HTTP-Statuscode liefert, statt die Ausnahme als 500 durchzureichen. Tests der Datenbankbedingungen versuchen widersprüchliche Einfügungen und prüfen, dass die API 409 zurückgibt, statt einen unbehandelten Integritätsfehler auszulösen.
Diese Abdeckung entsteht nicht von selbst, wenn Tests nachträglich geschrieben werden. Fehlerfälle müssen beim Testentwurf bewusst aufgelistet werden. Das ist ein überzeugendes Argument dafür, Tests zusammen mit dem Code zu schreiben: Fehlerfälle lassen sich leichter benennen, wenn die Implementierung noch frisch ist und die Randfälle noch präsent sind.
Der Nutzen nachträglicher Tests
Drei Monate zu spät zu testen ist besser, als nie zu testen – aus ganz praktischen Gründen. Beim Schreiben des PRs wurden drei bestehende Fehler gefunden: eine fehlende Null-Prüfung eines optionalen Adressfelds, die bei Bestellungen ohne zweite Rechnungsadresszeile einen 500-Fehler auslöste; ein Fehler um eins in der Paginierungslogik, der nur bei leeren Ergebnismengen auftrat; und ein falscher HTTP-Statuscode (200 statt 201) an einem Endpunkt zum Erstellen von Ressourcen. Diese Fehler waren monatelang im Produktivbetrieb, ohne Nutzerbeschwerden auszulösen – genau deshalb waren sie unentdeckt geblieben. Die Tests fanden sie an einem Nachmittag.
Der nachhaltigere Nutzen liegt in der Dokumentation des Verhaltens. Eine Testsuite, die Authentifizierungsgrenzen, Interaktionen mit externen APIs und Fehlerbedingungen abdeckt, beschreibt das API-Verhalten und bleibt korrekt, solange die Tests bestehen. Der Test „Bediener darf nicht auf Administratorendpunkt zugreifen“ erkennt nicht nur Regressionen, sondern vermittelt auch dem nächsten Leser der Codebasis die Absicht. Für einen Dienst, den später jemand anderes als sein ursprünglicher Autor pflegt – was irgendwann für jeden Dienst gilt –, ist diese Spezifikation erheblich wertvoller als die beim Schreiben gefundenen Fehler.
Gegen spät geschriebene Tests spricht der gebündelte Anfangsaufwand: ein großer PR, eine konzentrierte Prüfung und Zeit, die für Funktionen hätte genutzt werden können. Das stimmt. Die Alternative – eine dauerhaft ungetestete Codebasis, weil der richtige Zeitpunkt für Tests nie kommt – verursacht jedoch wachsenden Aufwand. Dieser wird selten gemessen, weil er sich über jede spätere Änderung verteilt. Eine spät ergänzte Testsuite ist einmal teuer. Keine Testsuite ist dauerhaft teuer.