APITests mit Pytest für FastAPI und Flask superaufladen
Lukas Schneider
DevOps Engineer · Leapcell

Einleitung im schnellen Umfeld der Backend-Entwicklung ist die Bereitstellung robuster und zuverlässiger APIs von größter Bedeutung. Egal, ob Sie Microservices mit FastAPI oder traditionelle Webanwendungen mit Flask erstellen, stellen Sie sicher, dass Ihr Code unter verschiedenen Bedingungen wie erwartet funktioniert, ist nicht verhandelbar. Hier kommen effektive Tests ins Spiel. Während viele Entwickler die Bedeutung von Tests verstehen, kann das Schreiben guter, wartbarer und effizienter Tests oft als lästige Pflicht empfunden werden. Dieser Artikel zielt darauf ab, den Prozess zu entmystifizieren und zu zeigen, wie Pytest, ein leistungsstarkes und flexibles Testframework, genutzt werden kann, um qualitativ hochwertige Unit-Tests für Ihre FastAPI- und Flask-Anwendungen zu schreiben. Wir werden Tools und Techniken untersuchen, die nicht nur Ihre Codequalität verbessern, sondern auch Ihren Entwicklungs-Workflow rationalisieren.
Kernkonzepte für effektive API-Tests
Bevor wir uns mit praktischen Beispielen befassen, möchten wir ein gemeinsames Verständnis einiger Schlüsselbegriffe schaffen, die für unsere Diskussion zentral sind.
Unit Test
Ein Unit-Test konzentriert sich auf das Testen der kleinsten testbaren Teile einer Anwendung, typischerweise einzelner Funktionen oder Methoden, isoliert von anderen Komponenten. Ziel ist es, zu überprüfen, ob jede Codeeinheit ihre beabsichtigte Funktionalität korrekt ausführt.
Integration Test
Integrationstests überprüfen, ob verschiedene Module oder Dienste innerhalb einer Anwendung korrekt miteinander interagieren. Für eine API könnte dies das Testen des gesamten Anforderungs-Antwort-Zyklus bedeuten, einschließlich Datenbankinteraktionen oder externer API-Aufrufe.
Pytest
Pytest ist ein beliebtes und ausgereiftes Testframework für Python. Es ist bekannt für seine Einfachheit, Erweiterbarkeit und seine leistungsstarken Funktionen wie Fixtures, Parameterisierung und eine Plugin-Architektur, die das Schreiben und Ausführen von Tests hocheffizient machen.
Fixtures
Pytest-Fixtures sind Funktionen, die eine Basisumgebung für die Ausführung von Tests einrichten und diese auch wieder abbauen können. Sie sind eine leistungsstarke Möglichkeit, Testabhängigkeiten zu verwalten, z. B. die Bereitstellung eines Testclients, einer Datenbankverbindung oder von Mock-Objekten.
Monkeypatching
Monkeypatching ist die dynamische Modifikation einer Klasse oder eines Moduls zur Laufzeit. Beim Testen wird es häufig verwendet, um Teile des Systems durch Mock-Objekte oder vereinfachte Implementierungen zu ersetzen, wodurch die zu testende Einheit von ihren externen Abhängigkeiten isoliert wird.
Mocking
Mocking beinhaltet die Erstellung simulierter Objekte, die das Verhalten realer Objekte nachahmen. Mocks werden üblicherweise verwendet, um externe Dienste, Datenbanken oder komplexe Komponenten zu ersetzen, die in einer isolierten Testumgebung schwer zu steuern sind.
Prinzipien des Testens von FastAPI- und Flask-Anwendungen
Das Grundprinzip beim Testen von Webanwendungen, insbesondere mit Unit-Tests, besteht darin, die zu testende Logik zu isolieren. Für FastAPI und Flask bedeutet dies, die Routenhandler, die Geschäftslogik und die Hilfsfunktionen unabhängig vom laufenden Server zu testen. Pytest bietet hervorragende Werkzeuge, um dies zu erreichen.
Testen von FastAPI-Anwendungen
FastAPI-Anwendungen basieren auf Starlette, das einen APITestClient
für die synchrone und asynchrone Ausführung von HTTP-Anforderungen an Ihre Anwendung bereitstellt, ohne einen Live-Server ausführen zu müssen. Dieser Client ist ideal für Unit-Tests im Integrationsstil, die mit Ihren API-Endpunkten interagieren.
Betrachten wir eine einfache FastAPI-Anwendung:
# main.py from fastapi import FastAPI, HTTPException from pydantic import BaseModel from typing import List, Dict app = FastAPI() class Item(BaseModel): name: str price: float db: Dict[str, Item] = {} @app.post("/items/", response_model=Item) async def create_item(item: Item): if item.name in db: raise HTTPException(status_code=400, detail="Item already exists") db[item.name] = item return item @app.get("/items/{item_name}", response_model=Item) async def read_item(item_name: str): if item_name not in db: raise HTTPException(status_code=404, detail="Item not found") return db[item_name] @app.get("/items/", response_model=List[Item]) async def read_all_items(): return list(db.values())
Schreiben wir nun einige Pytest-Tests dafür:
# test_main.py import pytest from fastapi.testclient import TestClient from main import app, db, Item # Löschen Sie die Datenbank vor jedem Test @pytest.fixture(autouse=True) def clear_db(): db.clear() yield @pytest.fixture(scope="module") def client(): with TestClient(app) as c: yield c def test_create_item(client): response = client.post("/items/", json={"name": "apple", "price": 1.99}) assert response.status_code == 200 assert response.json() == {"name": "apple", "price": 1.99} assert "apple" in db assert db["apple"].dict() == {"name": "apple", "price": 1.99} def test_create_existing_item(client): client.post("/items/", json={"name": "apple", "price": 1.99}) response = client.post("/items/", json={"name": "apple", "price": 2.99}) assert response.status_code == 400 assert response.json() == {"detail": "Item already exists"} def test_read_item(client): client.post("/items/", json={"name": "banana", "price": 0.79}) response = client.get("/items/banana") assert response.status_code == 200 assert response.json() == {"name": "banana", "price": 0.79} def test_read_non_existent_item(client): response = client.get("/items/grape") assert response.status_code == 404 assert response.json() == {"detail": "Item not found"} def test_read_all_items(client): client.post("/items/", json={"name": "orange", "price": 0.50}) client.post("/items/", json={"name": "pear", "price": 0.90}) response = client.get("/items/") assert response.status_code == 200 assert len(response.json()) == 2 assert {"name": "orange", "price": 0.50} in response.json() assert {"name": "pear", "price": 0.90} in response.json()
Hier stellt die client
-Fixture eine TestClient
-Instanz bereit, die als simulierter Browser fungiert. Die clear_db
-Fixture stellt sicher, dass unsere In-Memory-Datenbank für jeden Test sauber ist, um Testinterferenzen zu vermeiden.
Testen von Flask-Anwendungen
Flask stellt ebenso einen Testclient bereit, der es Ihnen ermöglicht, Anforderungen an Ihre Anwendung zu simulieren.
Betrachten Sie eine einfache Flask-Anwendung:
# app.py from flask import Flask, jsonify, request, abort app = Flask(__name__) db = {} # In-Memory-Datenbank @app.route("/items", methods=["POST"]) def create_item(): item_data = request.get_json() name = item_data.get("name") price = item_data.get("price") if not name or not price: abort(400, "Name and price are required") if name in db: abort(409, "Item already exists") # Conflict db[name] = {"name": name, "price": price} return jsonify(db[name]), 201 @app.route("/items/<string:item_name>", methods=["GET"]) def get_item(item_name): item = db.get(item_name) if not item: abort(404, "Item not found") return jsonify(item), 200 @app.route("/items", methods=["GET"]) def get_all_items(): return jsonify(list(db.values())), 200 if __name__ == "__main__": app.run(debug=True)
Und seine Pytest-Tests:
# test_app.py import pytest from app import app, db @pytest.fixture(autouse=True) def clear_db(): db.clear() yield @pytest.fixture(scope="module") def client(): app.config['TESTING'] = True with app.test_client() as client: yield client def test_create_item(client): response = client.post("/items", json={"name": "laptop", "price": 1200.00}) assert response.status_code == 201 assert response.json == {"name": "laptop", "price": 1200.00} assert "laptop" in db assert db["laptop"] == {"name": "laptop", "price": 1200.00} def test_create_item_missing_data(client): response = client.post("/items", json={"name": "keyboard"}) assert response.status_code == 400 assert "Name and price are required" in response.get_data(as_text=True) def test_create_item_existing(client): client.post("/items", json={"name": "mouse", "price": 25.00}) response = client.post("/items", json={"name": "mouse", "price": 30.00}) assert response.status_code == 409 assert "Item already exists" in response.get_data(as_text=True) def test_get_item(client): client.post("/items", json={"name": "monitor", "price": 300.00}) response = client.get("/items/monitor") assert response.status_code == 200 assert response.json == {"name": "monitor", "price": 300.00} def test_get_non_existent_item(client): response = client.get("/items/webcam") assert response.status_code == 404 assert "Item not found" in response.get_data(as_text=True) def test_get_all_items(client): client.post("/items", json={"name": "desk", "price": 150.00}) client.post("/items", json={"name": "chair", "price": 75.00}) response = client.get("/items") assert response.status_code == 200 assert len(response.json) == 2 assert {"name": "desk", "price": 150.00} in response.json assert {"name": "chair", "price": 75.00} in response.json
Die client
-Fixture für Flask verwendet app.test_client()
, um einen Testclient zu erhalten, und app.config['TESTING'] = True
stellt sicher, dass Flask im Testmodus ist, was sich auf Fehlerbehandlung und andere Verhaltensweisen auswirken kann. Die clear_db
-Fixture dient demselben Zweck wie im FastAPI-Beispiel.
Mocking und Dependency Injection
Für komplexere Szenarien, insbesondere wenn Ihre API mit externen Diensten (Datenbanken, Drittanbieter-APIs, Nachrichtenwarteschlangen) interagiert, wird Mocking für echte Unit-Tests unerlässlich. Pytest bietet in Kombination mit unittest.mock
(Teil der Python-Standardbibliothek) leistungsstarke Mocking-Funktionen.
Betrachten Sie eine Service-Schicht, die mit einer Datenbank interagiert:
# services.py class Database: def get_user(self, user_id: str): # Stellen Sie sich hier komplexe Datenbanklogik vor if user_id == "123": return {"id": "123", "name": "Alice"} return None def create_user(self, user_data: dict): # Stellen Sie sich hier Datenbankeinfüge- return {"id": "new_id", **user_data} db_service = Database() # In Ihrer FastAPI/Flask-App: # from .services import db_service # @app.get("/users/{user_id}") # async def get_user_route(user_id: str): # user = db_service.get_user(user_id) # if not user: # raise HTTPException(status_code=404, detail="User not found") # return user
Um get_user_route
effektiv zu testen, ohne auf eine echte Datenbank zuzugreifen:
# test_services.py from unittest.mock import MagicMock import pytest from main import app # Angenommen, Ihr main.py importiert db_service from services import db_service # Angenommen, Ihre App verwendet die db_service @pytest.fixture def mock_db_service(monkeypatch): mock = MagicMock() monkeypatch.setattr("main.db_service", mock) # Patcht die importierte Instanz in main.py return mock def test_get_user_route_exists(client, mock_db_service): mock_db_service.get_user.return_value = {"id": "456", "name": "Bob"} response = client.get("/users/456") assert response.status_code == 200 assert response.json() == {"id": "456", "name": "Bob"} mock_db_service.get_user.assert_called_once_with("456") def test_get_user_route_not_found(client, mock_db_service): mock_db_service.get_user.return_value = None response = client.get("/users/unknown") assert response.status_code == 404 assert response.json() == {"detail": "User not found"}
In diesem Beispiel wird monkeypatch
verwendet, um die db_service
-Instanz innerhalb des main
-Moduls durch eine MagicMock
zu ersetzen. Dies ermöglicht es uns, die Rückgabewerte der db_service
-Methoden zu steuern und zu überprüfen, ob sie korrekt aufgerufen wurden.
Fazit
Das Schreiben effizienter Unit-Tests für Ihre FastAPI- und Flask-Anwendungen ist nicht nur dazu da, Fehler zu finden, sondern auch, Vertrauen in Ihren Code aufzubauen, Refactoring zu erleichtern und die Entwicklung zu beschleunigen. Durch die Nutzung der leistungsstarken Funktionen von Pytest wie Fixtures und durch die Nutzung von Testclients und Mocking-Techniken können Sie eine robuste Testsuite erstellen, die die Zuverlässigkeit und Korrektheit Ihrer APIs gewährleistet. Letztendlich ist ein gut getestetes Backend ein widerstandsfähiges und wartbares Backend.