Flask-Apps testen: der Test-Client und zuverlässige Endpoints
Wie ich in meinen Schulungen Flask-Endpunkte teste: mit app.test_client() ohne laufenden Server, pytest-Fixtures und geprüften JSON-Antworten.
In fast jeder meiner Backend-Schulungen kommt irgendwann der Moment, in dem jemand einen frisch gebauten Flask-Endpunkt vorführt, den Browser öffnet, curl abfeuert und zufrieden nickt. Der Endpunkt funktioniert. Genau da hake ich ein und frage: Und morgen? Und nach dem nächsten Refactoring? Und wenn dein Kollege das Verhalten aus Versehen ändert? Ein manueller Klick ist ein Beweis für genau diesen einen Augenblick. Ein Test ist ein Beweis, den ich jederzeit wiederholen kann, ohne einen Server hochzufahren und ohne selbst hinzuschauen.
In diesem Artikel zeige ich, wie ich Flask-Anwendungen so teste, dass sie zuverlässig bleiben. Wir bauen auf dem auf, was ich in Web-APIs mit Flask beschrieben habe, und schauen uns diesmal die andere Seite an: nicht das Bauen der Endpunkte, sondern das Absichern. Das Werkzeug dafür ist erstaunlich schlank – Flask bringt mit app.test_client() alles mit, was wir brauchen, und pytest liefert die Struktur drumherum.
Ein Test ohne Server
Der erste Reflex vieler Teilnehmer ist, für einen Test die App zu starten und dann per HTTP-Bibliothek dagegen zu schießen. Das funktioniert, ist aber langsam, fehleranfällig und braucht einen freien Port. Flask bietet einen deutlich besseren Weg an. app.test_client() liefert einen Client, der die WSGI-Anwendung direkt im selben Prozess aufruft. Es gibt keinen Socket, keinen app.run(), keine echte Netzwerkverbindung – der Request wird als Python-Aufruf an die App durchgereicht und die Antwort kommt als Objekt zurück.
Das ist der entscheidende Punkt, den ich in Schulungen immer betone: Der Test spricht mit deiner Anwendung, aber nicht über das Netz. Dadurch laufen die Tests schnell, sind deterministisch und funktionieren auch in einer CI-Umgebung ohne offene Ports.
flowchart LR
A[Test] -->|client.get / post| B[test_client]
B -->|WSGI in-process<br/>kein Socket, kein Server| C[Flask-App]
C --> D[View-Funktion]
D -->|Response<br/>status_code + body| B
B -->|Response-Objekt| A
A -->|assert| E[Status & Body geprüft]
Der Client verhält sich wie ein kleiner Browser ohne Oberfläche. Ich rufe client.get("/") oder client.post("/json", json={...}) auf und bekomme ein Response-Objekt zurück, das ich untersuchen kann. Die wichtigsten Attribute sind schnell aufgezählt: response.status_code als Ganzzahl, response.data als Rohbody in Bytes und response.get_json() für bereits deserialisierte JSON-Antworten.
Vom unittest-Stil zu pytest-Fixtures
Viele ältere Flask-Beispiele – auch das, mit dem ich früher in meinem Repository introduction-python gearbeitet habe – nutzen unittest. Der Aufbau sieht dann typischerweise so aus:
import unittest
from examples.app import app
class HelloTestCase(unittest.TestCase):
def setUp(self):
app.testing = True
self.client = app.test_client()
def test_index(self):
result = self.client.get("/")
self.assertEqual(result.status_code, 202)
self.assertEqual(result.data, b"Hello World!")
if __name__ == "__main__":
unittest.main()
Das funktioniert und ist ein guter Ausgangspunkt. Der Endpunkt in meinem Beispiel gibt bewusst den ungewöhnlichen Status 202 zurück – genau deshalb eignet er sich zum Testen, denn ein falscher Status fällt sofort auf. Trotzdem modernisiere ich das inzwischen zu pytest, und zwar aus zwei Gründen: Die Tests werden kürzer und lesbarer, und die Fixtures machen das Setup wiederverwendbar, ohne dass ich es in jeder Testklasse wiederhole.
Der zweite, subtilere Grund liegt im Detail des Codes oben. Die App wird als Modul-globales Objekt importiert und der Client teilt sich diese eine Instanz. Solange die App zustandslos ist, geht das gut. Sobald sie Daten hält, wird es heikel – dazu später mehr.
Fixtures in conftest.py
Bei pytest lege ich das Setup in eine Datei tests/conftest.py. Diese Datei wird von pytest automatisch gefunden, und die dort definierten Fixtures stehen allen Tests im Verzeichnis zur Verfügung. Ich definiere zwei Fixtures: eine für die konfigurierte App und eine für den Client.
import pytest
from examples.app import app as flask_app
@pytest.fixture
def app():
flask_app.config.update({"TESTING": True})
yield flask_app
@pytest.fixture
def client(app):
return app.test_client()
Zwei Dinge sind hier wichtig. Erstens die Konfiguration TESTING = True. Sie schaltet die Fehlerpropagierung ein: Wirft ein View-Fehler eine Exception, reicht Flask sie im Testmodus durch, statt sie in eine generische 500-Antwort zu verpacken. Ohne dieses Flag debuggst du im Blindflug, weil du nur den Status 500 siehst und nicht den eigentlichen Stacktrace. Ich habe schon mehr als eine Schulungsgruppe eine halbe Stunde nach einem Fehler suchen sehen, der mit TESTING=True sofort sichtbar gewesen wäre.
Zweitens die client-Fixture, die von der app-Fixture abhängt. pytest löst diese Abhängigkeit automatisch auf: Es baut erst die App, dann den Client. Jeder Test, der client als Argument deklariert, bekommt eine frische, sauber konfigurierte Instanz. Genau diese Frische ist der Schlüssel zu isolierten Tests.
Die AAA-Struktur
Wenn ich Tests schreibe, halte ich mich an ein Muster, das ich in Schulungen als AAA einführe: Arrange, Act, Assert. Erst den Zustand vorbereiten, dann die eine Aktion ausführen, dann prüfen. Bei Flask-Tests fällt das Arrange größtenteils in die Fixtures, das Act ist der Request und das Assert die Prüfung der Antwort.
flowchart TD
A["Arrange<br/>Fixtures: App + Client<br/>TESTING = True"] --> B["Act<br/>client.get / client.post"]
B --> C["Assert<br/>status_code prüfen<br/>get_json / data prüfen"]
Ein erster, einfacher Test für den Index-Endpunkt sieht damit so aus:
def test_index_returns_hello(client):
# Act
response = client.get("/")
# Assert
assert response.status_code == 202
assert response.data == b"Hello World!"
Kein setUp, keine Klasse, kein self. Die Fixture erledigt das Arrange, der Test bleibt bei der eigentlichen Aussage: Dieser Endpunkt antwortet mit Status 202 und dem Body Hello World!.
Bytes sind nicht String
An dieser Stelle stolpern fast alle Teilnehmer einmal, und ich lasse sie bewusst hineinlaufen. Der naheliegende Vergleich
assert response.data == "Hello World!" # fails
schlägt fehl. response.data liefert Bytes, kein str. Der Vergleich zwischen b"Hello World!" und "Hello World!" ist in Python immer False. Es gibt zwei saubere Wege: Entweder ich vergleiche gegen ein Byte-Literal b"Hello World!", oder ich hole den Body als Text.
def test_index_body_as_text(client):
response = client.get("/")
assert response.get_data(as_text=True) == "Hello World!"
get_data(as_text=True) dekodiert den Body zu einem String. Beide Varianten sind korrekt – ich wähle je nachdem, ob ich Rohbytes oder lesbaren Text prüfen will. Was ich in diesem Kontext bewusst nicht nutze, ist response.text; diese String-Eigenschaft kam erst mit einer späteren Werkzeug-Version und steht bei Flask 1.1 noch nicht zur Verfügung.
JSON-Antworten prüfen
Die meisten realen Endpunkte liefern kein Hello World!, sondern JSON. Hier zeigt sich der zweite große Vorteil des Test-Clients. Statt den Rohbody selbst zu parsen, ruft man response.get_json() auf und bekommt bereits eine fertige Python-Struktur – ein dict oder eine list. Das trennt zwei Prüfebenen sauber voneinander: der Body als Bytes für den exakten Rohvergleich und die deserialisierte Struktur für die inhaltliche Prüfung.
Nehmen wir einen Endpunkt, der ein JSON-Objekt entgegennimmt, es um Felder ergänzt und zurückgibt – ähnlich dem /json-Beispiel aus meinem Repository:
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route("/json", methods=["POST"])
def json_endpoint():
payload = request.json
result = {"username": "John Doe", "role": payload.get("role")}
return jsonify(result)
Der zugehörige Test schickt JSON hin und prüft die deserialisierte Antwort:
def test_json_endpoint(client):
# Act
response = client.post("/json", json={"role": "admin"})
# Assert
assert response.status_code == 200
data = response.get_json()
assert data["username"] == "John Doe"
assert data["role"] == "admin"
Ein Detail, das ich gern hervorhebe: Der json=-Parameter beim Client setzt automatisch den Content-Type auf application/json und serialisiert das Dictionary. Man muss also weder Header von Hand setzen noch selbst json.dumps aufrufen. Auf der Antwortseite nimmt get_json() einem das Parsen ab. Der Test bleibt dadurch nah an der eigentlichen Aussage über das Verhalten des Endpunkts.
Fehlerfälle gehören dazu
Ein häufiger Fehler in Schulungen ist, nur den glücklichen Pfad zu testen. Ein Endpunkt, der bei korrekter Eingabe funktioniert, aber bei falscher Eingabe still das Falsche tut, ist nicht zuverlässig. Deshalb gehört zu jedem ernstgemeinten Testsatz mindestens ein Fehlerfall. Der einfachste ist die unbekannte Route:
def test_unknown_route_returns_404(client):
response = client.get("/does-not-exist")
assert response.status_code == 404
Was ich mindestens abdecke, lässt sich in eine kurze Liste fassen:
- Der Status-Code – erwarte ich
200,202,201oder einen bewussten Fehlercode? - Der Response-Body beziehungsweise die JSON-Struktur – stimmen die Felder und Werte?
- Die Fehlerfälle – etwa
404für unbekannte Routen und400für ungültige Eingaben.
Diese drei Ebenen sind nicht viel Arbeit, verändern aber die Aussagekraft eines Testsatzes grundlegend. Ein Test, der nur den Status prüft, übersieht falsche Inhalte. Ein Test, der nur den Body prüft, übersieht einen falschen Status. Zusammen ergeben sie ein belastbares Bild.
Der Fallstrick: geteilter Zustand
Jetzt kommen wir zu dem Problem, das ich weiter oben angedeutet habe und das in der Praxis die meisten schwer auffindbaren Testfehler verursacht: geteilter Zustand zwischen Tests. Solange eine App zustandslos ist, teilen sich alle Tests gefahrlos dieselbe Instanz. Sobald die App aber Daten hält – eine Liste im Speicher, ein globales Dictionary, eine Datenbank –, wird die Reihenfolge der Tests plötzlich relevant.
Stell dir einen Endpunkt vor, der Einträge in einer modul-globalen Liste sammelt. Ein Test legt einen Eintrag an, ein anderer zählt die Einträge. Läuft der Zähl-Test nach dem Anlege-Test, sieht er einen Eintrag mehr als erwartet. Läuft er davor, sieht er null. Solche Tests sind mal grün, mal rot, je nach Reihenfolge – und nichts ist frustrierender als ein Test, der ohne Codeänderung mal so und mal so ausgeht.
Die Lösung liegt in den Fixtures. Statt eine globale App zu importieren, baue ich sie pro Test frisch auf. pytest-Fixtures haben standardmäßig den Geltungsbereich function, das heißt, sie werden für jeden Testaufruf neu ausgeführt. Wenn die Fixture bei jedem Aufruf eine App mit leerem Zustand liefert, kann kein Test den nächsten beeinflussen.
Datenbanken sauber isolieren
Der Zwilling dieses Problems ist die nicht isolierte Datenbank. Schreiben Tests in dieselbe Datenbank, ohne aufzuräumen, verunreinigen sie einander genauso wie die globale Liste. Ein Test legt einen Nutzer an, der nächste findet ihn vor und rechnet nicht damit.
Die saubere Antwort ist Setup und Teardown rund um das yield der Fixture. Alles vor dem yield richtet den Zustand ein, alles danach räumt auf. In der Praxis nutze ich für Tests gern eine frische temporäre SQLite-Datenbank pro Test oder ein Transaktions-Rollback am Ende. Das Muster sieht schematisch so aus:
import pytest
from myapp import create_app, db
@pytest.fixture
def app():
app = create_app({"TESTING": True, "DATABASE": ":memory:"})
with app.app_context():
db.create_all() # Setup: fresh schema
yield app
db.drop_all() # Teardown: clean up
@pytest.fixture
def client(app):
return app.test_client()
Der Kern ist das yield in der Mitte. Davor steht das Anlegen des Schemas, danach das Aufräumen. Jeder Test bekommt so eine leere Datenbank, arbeitet darin und hinterlässt keine Spuren für den nächsten. Das ist etwas mehr Aufwand als eine geteilte Datenbank, aber es ist der Unterschied zwischen Tests, denen ich vertraue, und Tests, die ich bei jedem roten Lauf erst einmal misstrauisch anschaue.
Was am Ende zählt
Wenn ich diesen Teil einer Schulung abschließe, fasse ich es meist so zusammen: Der Test-Client von Flask nimmt dir die Ausrede, Endpunkte seien schwer zu testen. Es braucht keinen laufenden Server und keine Netzwerkbibliothek, nur app.test_client(), ein paar Fixtures und die Disziplin, Status, Body und Fehlerfälle gemeinsam zu prüfen.
Die drei Ideen, die ich mitgebe, sind schlicht. Erstens: Teste in-process, ohne Server, dann sind deine Tests schnell und deterministisch. Zweitens: Nutze pytest-Fixtures, um jedem Test eine frische, konfigurierte App zu geben – das ist deine beste Verteidigung gegen geteilten Zustand. Drittens: Denke bei allem, was Daten hält, an Setup und Teardown, sonst hängt das Ergebnis an der Testreihenfolge. Wer diese drei Punkte beherzigt, hat Endpunkte, auf die er sich auch nach dem nächsten Refactoring verlassen kann. Und genau das ist der Unterschied zwischen Code, der heute läuft, und Code, der zuverlässig bleibt – ein Gedanke, der sich durch alles zieht, was ich in Python für Entwickler, die Systeme verstehen wollen beschrieben habe.
Weiterführende Quellen
- Repository
introduction-python,web/4-testing.mdsowie die Beispieleexamples/5-testing.py,examples/app.pyundexamples/3-json.py - Flask, Testing Flask Applications: https://flask.palletsprojects.com/en/1.1.x/testing/
- Flask, API-Referenz zu
Flask.test_clientund derTESTING-Config: https://flask.palletsprojects.com/en/1.1.x/api/ - Werkzeug, Test-Utilities und Response-Objekt (
get_json,data): https://werkzeug.palletsprojects.com/en/1.0.x/test/ - pytest, Fixtures: https://docs.pytest.org/en/6.2.x/fixture.html
- Flask-Tutorial, Test Coverage: https://flask.palletsprojects.com/en/1.1.x/tutorial/tests/
Kommentare
Kommentar schreiben