Blog

Flask-Apps testen: der Test-Client und zuverlässige Endpoints

Wie ich Flask-Endpunkte teste: mit app.test_client() ohne laufenden Server, pytest-Fixtures und geprüften JSON-Antworten.

≈ 8 Min. Lesezeit

Diesen Beitrag anhören (12 Min.)

MP3 herunterladen

app.test_client() ist kein HTTP-Client. Er öffnet keinen Socket, belegt keinen Port und braucht kein app.run(). Er ruft die WSGI-Anwendung direkt im selben Prozess auf und gibt zurück, was die View-Funktion produziert hat – ein Python-Objekt, keine Netzwerkantwort.

Damit fällt die Begründung weg, Endpunkte seien aufwendig abzusichern. Ein curl im Terminal zeigt, dass eine Route in diesem einen Augenblick das Richtige tut; nach dem nächsten Refactoring sagt es nichts mehr aus. Dieser Artikel baut auf Web-APIs mit Flask auf und dreht die Perspektive um: nicht das Bauen der Endpunkte, sondern das Absichern. Das Werkzeug dafür ist schlank – Flask bringt den Test-Client mit, pytest liefert die Struktur drumherum.

Ein Test ohne Server

Der naheliegende Weg wäre, 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: Der Test spricht mit deiner Anwendung, aber nicht über das Netz. Dadurch laufen die Tests schnell und funktionieren auch in einer CI-Umgebung ohne offene Ports. Die fehlende Netzwerk- und Serverstrecke entfernt einige Schwankungsquellen; deterministisch wird der Test aber erst, wenn auch Daten, Zeit und externe Abhängigkeiten kontrolliert sind.

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 &amp; 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 in einem Projekt eine halbe Stunde in einem Testlauf nach einer Ursache gesucht, die mit TESTING=True von Anfang an im Traceback gestanden hätte.

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 das Muster AAA: 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

Über diese Stelle bin ich selbst oft genug gestolpert. 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 lohnt die Erwähnung: 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, der mir in Code-Reviews begegnet, ist, nur den Happy Path 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, 201 oder einen bewussten Fehlercode?
  • Der Response-Body beziehungsweise die JSON-Struktur – stimmen die Felder und Werte?
  • Die Fehlerfälle – etwa 404 für unbekannte Routen und 400 fü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

Dasselbe Problem eine Nummer größer ist die nicht isolierte Datenbank. Schreiben Tests in dieselbe Datenbank, ohne aufzuräumen, kommen sie sich genauso in die Quere wie bei der globalen 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

Der Test-Client von Flask macht das Absichern von Endpunkten zu einer Fleißaufgabe statt zu einem Infrastrukturthema. 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 sich für mich bewährt haben, sind schlicht. Erstens: in-process testen, ohne Server – dann sind die Tests schnell und unabhängig von Port und Netzwerk. Zweitens: pytest-Fixtures nutzen, damit jeder Test eine frische, konfigurierte App bekommt – die beste Verteidigung gegen geteilten Zustand. Drittens: bei allem, was Daten hält, an Setup und Teardown denken, sonst hängt das Ergebnis an der Testreihenfolge. Damit lässt sich die WSGI-Anwendung verlässlich gegen ihre HTTP-Semantik prüfen. Den realen Weg über WSGI-Server, Reverse Proxy, TLS und Netzwerk deckt diese Testebene bewusst nicht ab.

Weiterführende Quellen

Wie fandest du diesen Beitrag?

Kommentare