13. Automatisierte Tests mit pytest¶
Python-Anwendungen mit pytest testen, Testdaten verwalten, externe Abhängigkeiten mocken und Test-Driven Development praktisch anwenden.
Professionelle Softwareentwicklung ohne automatisierte Tests ist heute kaum noch vorstellbar. Tests helfen dabei, Fehler frühzeitig zu erkennen, Refactorings sicher durchzuführen und die Qualität einer Anwendung langfristig zu sichern.
In der Python-Welt hat sich pytest als De-facto-Standard etabliert. Das Framework bietet eine sehr einfache Syntax, leistungsfähige Assertions und ein flexibles Fixture-System.
In diesem Kapitel lernen Sie, wie pytest installiert und verwendet wird, wie Testdaten bereitgestellt werden, wie externe Abhängigkeiten gemockt werden und wie Test-Driven Development in Python umgesetzt werden kann.
Einführung in pytest¶
Motivation für automatisierte Tests mit pytest¶
Automatisierte Tests sind unverzichtbar, um Softwarequalität sicherzustellen und Änderungen schnell und zuverlässig zu validieren. Python bietet mit pytest ein modernes, flexibles Framework, das sich von klassischen xUnit-Frameworks wie JUnit oder NUnit durch seine einfache Syntax, mächtige Features und Python-idiomatische Arbeitsweise unterscheidet.
Unterschiede zwischen unittest und pytest¶
unittest ist Pythons Standard-Testframework, das stark an JUnit angelehnt ist. Es verwendet Klassen, setUp/tearDown-Methoden und erfordert oft Boilerplate-Code. Im Gegensatz dazu ist pytest funktional orientiert, erkennt Tests automatisch anhand von Namenskonventionen und nutzt Python-Features wie Generatoren und Fixtures, um Tests klarer und wartbarer zu gestalten.
Beispiel unittest:
import unittest
class TestAPI(unittest.TestCase):
def setUp(self):
self.client = APIClient()
def test_status(self):
response = self.client.get('/status')
self.assertEqual(response.status_code, 200)
Äquivalent mit pytest:
import pytest
@pytest.fixture
def client():
return APIClient()
def test_status(client):
response = client.get('/status')
assert response.status_code == 200
pytest verzichtet auf Klassen, nutzt assert-Statements direkt und bietet durch Fixtures eine flexible Wiederverwendbarkeit.
Installation mit uv¶
In modernen Python-Projekten wird uv als universeller Paketmanager verwendet. Installation von pytest erfolgt einfach als Dev-Abhängigkeit:
Erste Testdateien und Namenskonventionen¶
pytest erkennt Testmodule und Testfunktionen anhand von Namensmustern:
- Dateien:
test_*.pyoder*_test.py - Funktionen:
test_* - Klassen:
Test*(ohne__init__)
Beispiel Projektstruktur:
In test_app.py:
Tests ausführen mit uv¶
Tests werden im Projektverzeichnis mit
gestartet. pytest findet automatisch alle Testdateien und führt die Tests aus.
Assertion-Rewriting von pytest¶
Ein zentraler Vorteil von pytest ist das Assertion-Rewriting:
- Python-
assert-Statements werden von pytest so umgeschrieben, dass bei einem Fehler detaillierte Informationen über Ausdrucksteile ausgegeben werden.
Beispiel:
Fehlermeldung:
Dies erleichtert Debugging erheblich und reduziert redundante Fehlermeldungen.
Vergleich zu JUnit und NUnit¶
| Aspekt | pytest | JUnit / NUnit |
|---|---|---|
| Testdefinition | Funktionen, keine Klassen nötig | Klassenbasiert |
| Assertions | Python-assert mit Rewriting |
Spezielle Assert-Methoden |
| Fixtures | Flexible, deklarative Fixtures | Setup-/Teardown-Methoden |
| Parametrisierung | @pytest.mark.parametrize | Parameterized Tests mit Annotationen |
| Plugins | Umfangreiches Plugin-Ökosystem | Erweiterbar, aber oft komplexer |
pytest folgt der Python-Denkweise: Weniger Boilerplate, mehr Ausdruckskraft und Flexibilität. Dies führt zu besser lesbaren und wartbaren Tests, gerade in agilen Projekten.
Testorganisation, Fixtures und conftest.py¶
Testorganisation in pytest¶
In pytest sind Tests gewöhnlich in Modulen (Python-Dateien) mit dem Präfix test_ oder dem Suffix _test.py organisiert. Innerhalb dieser Module werden Funktionen oder Klassen mit Testmethoden definiert, die ebenfalls mit test_ beginnen. Diese Namenskonventionen ermöglichen pytest, Tests automatisch zu erkennen und auszuführen.
Ein typisches Projektverzeichnis könnte so aussehen:
Die Datei conftest.py ist ein spezieller Ort für gemeinsame Fixtures, die projektweit verfügbar sind.
Fixtures: Wiederverwendbare Testdaten und Setup¶
Fixtures sind in pytest zentrale Bausteine zur Bereitstellung von Testdaten oder zur Vorbereitung von Testumgebungen. Sie ersetzen klassische setUp- und tearDown-Methoden aus xUnit-Frameworks und bieten dabei eine flexiblere und deklarative API.
Ein Fixture wird mit dem Dekorator @pytest.fixture definiert und kann per Parameter-Injektion in Testfunktionen eingebunden werden:
import pytest
@pytest.fixture
def db_connection() -> str:
# Setup
conn = "DB-Verbindung hergestellt"
yield conn
# Teardown
print("DB-Verbindung geschlossen")
def test_query(db_connection: str) -> None:
assert db_connection == "DB-Verbindung hergestellt"
Der Einsatz von yield trennt Setup- und Teardown-Logik elegant in einem Fixture. Das ist idiomatischer als separate Methoden und unterstützt komplexe Ressourcenverwaltung.
Fixture-Scopes: Lebenszyklus steuern¶
Fixtures können unterschiedliche Lebensdauern (scope) haben:
function(Standard): Fixture wird für jeden Test neu erstellt.class: Einmal pro Testklasse.module: Einmal pro Testmodul.session: Einmal pro Testlauf.
Beispiel:
@pytest.fixture(scope="module")
def expensive_resource() -> str:
print("Setup expensive resource")
yield "resource"
print("Teardown expensive resource")
Der Scope beeinflusst Performance und Isolation. Ein zu großer Scope kann Seiteneffekte verursachen, ein zu kleiner Scope erhöht die Laufzeit.
conftest.py: Gemeinsame Fixtures zentralisieren¶
conftest.py ermöglicht das Teilen von Fixtures über mehrere Testmodule hinweg ohne explizite Imports. Das entspricht Dependency Injection, aber mit pytest-typischer automatischer Auflösung.
Diese implizite Verfügbarkeit fördert lose Kopplung und klare Teststruktur.
setup/teardown mit yield-Fixtures¶
Yield-Fixtures ersetzen klassische setUp/tearDown-Methoden durch einen einzigen Kontext. Das ist idiomatischer und vermeidet Boilerplate.
Mermaid-Diagramm zum Fixture-Lebenszyklus:
gantt
title Fixture-Lebenszyklus
dateFormat HH:mm:ss
section Setup
Fixture Setup :a1, 00:00:00, 2s
section Test Execution
Test läuft :after a1, 3s
section Teardown
Fixture Teardown :after a1, 2s
Vergleich zu SetUp/TearDown und Dependency Injection¶
In pytest sind Fixtures Funktionen, die per Parameter-Injektion in Tests eingebunden werden. Das ist flexibler und fördert Komposition statt Vererbung.
Dependency Injection erfolgt implizit über die Fixture-Namen, ohne explizite Registrierungsmechanismen oder Container. Das reduziert Boilerplate und erhöht die Lesbarkeit.
Best Practices¶
- Definiere kleine, fokussierte Fixtures, die genau eine Aufgabe erfüllen.
- Verwende
conftest.pyfür projektweite, wiederverwendbare Fixtures. - Wähle den Fixture-Scope bewusst, um Balance zwischen Isolation und Performance zu erreichen.
- Nutze
yieldfür Ressourcenmanagement und sauberes Setup/Teardown. - Vermeide globale Zustände in Fixtures, um Seiteneffekte zu minimieren.
Häufige Fehler¶
- Fixtures mit Seiteneffekten im
session-Scope, die Tests unvorhersehbar beeinflussen. - Zu große Fixtures, die mehrere Verantwortlichkeiten bündeln.
- Explizites Importieren von Fixtures aus
conftest.py(nicht nötig und kann zu Importfehlern führen).
Durch den konsequenten Einsatz von Fixtures und conftest.py wird pytest zur leistungsfähigen und eleganten Testplattform, die sich nahtlos in professionelle Python-Projekte integriert.
Parametrisierte Tests und Testdaten¶
Parametrisierte Tests mit @pytest.mark.parametrize¶
In pytest ermöglichen parametrisierte Tests, mehrere Testfälle mit unterschiedlichen Eingabewerten und erwarteten Ergebnissen in einer einzigen Testfunktion zu definieren. Dies vermeidet redundanten Code und erhöht die Wartbarkeit.
import pytest
@pytest.mark.parametrize(
"input_data,expected_status",
[
("/api/items", 200),
("/api/unknown", 404),
("/api/items/42", 200),
]
)
def test_api_endpoints(client, input_data: str, expected_status: int) -> None:
response = client.get(input_data)
assert response.status_code == expected_status
Hier definiert @pytest.mark.parametrize eine Liste von Tupeln, die jeweils unterschiedliche Parameterkombinationen repräsentieren. Die Testfunktion wird für jede Kombination ausgeführt.
Vorteile gegenüber einzelnen Tests¶
- Reduzierung von Boilerplate: Kein mehrfacher Funktionscode für ähnliche Szenarien.
- Klarheit: Testfälle sind übersichtlich in einer Datenstruktur zusammengefasst.
- Skalierbarkeit: Neue Testfälle lassen sich einfach hinzufügen.
Systematisches Testen von Grenzwerten und Sonderfällen¶
Parametrisierung eignet sich besonders, um Grenzwerte, fehlerhafte Eingaben und Sonderfälle systematisch abzudecken. Beispielsweise:
@pytest.mark.parametrize(
"username,password,expected",
[
("validUser", "correctPass", True),
("", "somePass", False), # leerer Benutzername
("user", "", False), # leeres Passwort
("user!", "pass", False), # ungültige Zeichen
("a" * 256, "pass", False), # zu lang
]
)
def test_user_auth(username: str, password: str, expected: bool) -> None:
result = authenticate(username, password)
assert result == expected
So wird die Robustheit der Funktion gegenüber unterschiedlichen Eingaben geprüft.
Lesbare Testberichte durch sprechende Parameter¶
pytest zeigt in Testberichten die Parameterwerte an, was die Fehlersuche erleichtert. Um die Lesbarkeit zu verbessern, können Parameter mit ids versehen werden:
@pytest.mark.parametrize(
"input,expected",
[(1, 2), (3, 4)],
ids=["eins_zu_zwei", "drei_zu_vier"]
)
def test_increment(input: int, expected: int) -> None:
assert increment(input) == expected
Die Testausgabe zeigt dann statt der reinen Werte die IDs.
Unterschied zwischen vielen einzelnen Tests und parametrierten Tests¶
pytest setzt auf parametrisierte Tests, um:
- Code-Duplizierung zu vermeiden
- Testdaten klar zu strukturieren
- Testfunktionen generisch und wiederverwendbar zu halten
Dies entspricht der Python-Philosophie, Code klar und prägnant zu halten.
Best Practices¶
- Verwenden Sie parametrisierte Tests, wenn sich Testlogik wiederholt, aber Eingabedaten variieren.
- Nutzen Sie sprechende Parameter-IDs für bessere Testberichte.
- Kombinieren Sie parametrisierte Tests mit Fixtures, um komplexe Testdaten sauber zu verwalten.
- Vermeiden Sie zu komplexe Parametrisierungen, die Testfunktionen unübersichtlich machen.
Häufige Fehler¶
- Parametrisierte Tests mit zu vielen Parametern und Kombinationen können unübersichtlich und langsam werden.
- Fehlende Typisierung der Parameter erschwert das Verständnis.
- Die Verwendung von parametrisierten Tests für völlig unterschiedliche Testlogiken sollte vermieden werden.
Mermaid-Diagramm: Ablauf parametrisierter Tests¶
gantt
title Parametrisierte Tests: Ablauf
dateFormat YYYY-MM-DD
section Testausführung
Testfunktion mit Parametern ausführen: done, 2024-01-01, 1d
Für jeden Parametersatz: done, 2024-01-02, 3d
- Test mit Parametern ausführen: done, 2024-01-02, 1d
- Ergebnis prüfen: done, 2024-01-03, 1d
- Nächstes Parameterset: done, 2024-01-04, 1d
Diese schematische Darstellung zeigt, wie pytest eine parametrisierte Testfunktion mehrfach mit unterschiedlichen Daten ausführt.
Mocking, Monkeypatching und API-Tests¶
Externe Abhängigkeiten isolieren mit Mocking und Monkeypatching¶
In der professionellen Softwareentwicklung sind Tests, die externe Abhängigkeiten wie Netzwerke oder Datenbanken involvieren, oft fehleranfällig, langsam und schwer reproduzierbar. Python bietet mit pytest und dem monkeypatch-Fixture eine elegante Möglichkeit, solche Abhängigkeiten zu isolieren, ohne die eigentliche Logik zu verändern.
Das monkeypatch-Fixture erlaubt es, Funktionen, Methoden oder Umgebungsvariablen temporär zu überschreiben – ideal für kontrollierte Testumgebungen.
Monkeypatching: Beispiel einer HTTP-Anfrage¶
Angenommen, wir haben eine Funktion, die eine HTTP-GET-Anfrage mit requests ausführt:
import requests
def fetch_user(user_id: int) -> dict:
response = requests.get(f'https://api.example.com/users/{user_id}')
response.raise_for_status()
return response.json()
Um diese Funktion ohne echten Netzwerkzugriff zu testen, nutzen wir monkeypatch:
def test_fetch_user(monkeypatch):
class MockResponse:
def raise_for_status(self):
pass # Kein Fehler
def json(self) -> dict:
return {'id': 42, 'name': 'Alice'}
def mock_get(url: str, *args, **kwargs):
assert url == 'https://api.example.com/users/42'
return MockResponse()
monkeypatch.setattr(requests, 'get', mock_get)
user = fetch_user(42)
assert user['name'] == 'Alice'
Hier ersetzt monkeypatch.setattr die requests.get-Methode temporär durch mock_get. Dies ist eine idiomatische Lösung, die in Python ohne zusätzliche Bibliotheken auskommt.
Mock-Objekte und Test-Doubles¶
Für komplexere Szenarien bietet sich das unittest.mock-Modul an, das auch in pytest nahtlos funktioniert. Es ermöglicht das Erstellen von Mock-Objekten, die Aufrufe protokollieren, Rückgabewerte definieren und Seiteneffekte simulieren.
from unittest.mock import Mock
mock_response = Mock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {'id': 1, 'status': 'ok'}
mock_get = Mock(return_value=mock_response)
# Beispiel: Patchen mit pytest-mock Plugin
def test_with_mock(mocker):
mocker.patch('requests.get', mock_get)
result = fetch_user(1)
assert result['status'] == 'ok'
mock_get.assert_called_once_with('https://api.example.com/users/1')
Der Vorteil von unittest.mock gegenüber einfachem Monkeypatching liegt in der erweiterten Kontrolle und der Möglichkeit, Aufrufe zu prüfen.
Simulation von Timeouts und HTTP-Fehlern¶
Robuste API-Tests erfordern das Simulieren von Fehlern und Ausnahmen. Mit monkeypatch oder unittest.mock lässt sich dies einfach realisieren:
import requests
import pytest
def test_fetch_user_timeout(monkeypatch):
"""
Simuliert einen Netzwerk-Timeout und prüft, ob fetch_user()
die Timeout-Exception an den Aufrufer weitergibt.
"""
def mock_get(*args, **kwargs):
raise requests.Timeout("Timeout during request")
monkeypatch.setattr(requests, "get", mock_get)
with pytest.raises(requests.Timeout):
fetch_user(1)
def test_fetch_user_http_error(monkeypatch):
"""
Simuliert eine HTTP-Fehlermeldung (404 Not Found) und prüft,
ob fetch_user() die HTTPError-Exception korrekt auslöst.
"""
class MockResponse:
def raise_for_status(self):
raise requests.HTTPError("404 Not Found")
def mock_get(*args, **kwargs):
return MockResponse()
monkeypatch.setattr(requests, "get", mock_get)
with pytest.raises(requests.HTTPError):
fetch_user(999)
Diese Tests gewährleisten, dass Fehlerfälle korrekt behandelt werden, ohne echte Netzwerkanfragen auszulösen.
API-Tests mit requests und pytest¶
Für Integrationstests von APIs ist es üblich, echte HTTP-Anfragen gegen eine Testinstanz zu senden. Um jedoch die Tests isoliert und schnell zu halten, empfehlen sich Mocking und Monkeypatching, wie oben gezeigt.
Alternativ kann man mit responses oder httpx Mock-Server simulieren, die HTTP-Anfragen abfangen und vorgefertigte Antworten liefern.
Zusammenfassung¶
monkeypatchist ein mächtiges Werkzeug, um externe Abhängigkeiten temporär zu ersetzen und kontrollierte Testumgebungen zu schaffen.unittest.mockbietet erweiterte Funktionen für Mock-Objekte, inklusive Aufrufüberprüfung.- Timeouts, HTTP-Fehler und ungültige Antworten lassen sich gezielt simulieren, um Fehlerbehandlung zu testen.
- Die Kombination dieser Techniken ermöglicht schnelle, zuverlässige und wartbare Tests ohne echte Netzwerkzugriffe.
FastAPI-Anwendungen testen¶
TestClient für FastAPI-Endpunkte¶
FastAPI integriert nahtlos mit starlette.testclient.TestClient, der auf requests basiert. Er ermöglicht synchrone Tests von asynchronen Endpunkten ohne echten HTTP-Server.
In der Praxis befinden sich Anwendung und Tests üblicherweise in getrennten Dateien. Die FastAPI-Anwendung wird in app.py definiert, während die Tests im Verzeichnis tests/ liegen. Über from app import app wird die Anwendung importiert und anschließend mit TestClient getestet.
# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from app import app
@pytest.fixture
def client() -> TestClient:
"""
Erstellt einen TestClient für die FastAPI-Anwendung.
pytest stellt die Fixture automatisch allen Tests bereit.
"""
return TestClient(app)
# app.py
from fastapi import FastAPI
app = FastAPI()
@app.get("/items/{item_id}")
async def read_item(item_id: int) -> dict:
return {
"item_id": item_id,
"name": f"Item {item_id}",
}
# tests/test_app.py
from fastapi.testclient import TestClient
def test_read_item(client: TestClient) -> None:
"""
Prüft, ob ein vorhandenes Item erfolgreich
über die API abgerufen werden kann.
"""
response = client.get("/items/42")
assert response.status_code == 200
assert response.json() == {
"item_id": 42,
"name": "Item 42",
}
Durch die Auslagerung des TestClient in eine Fixture wird gemeinsamer Setup-Code zentral verwaltet und kann von beliebig vielen Tests wiederverwendet werden. Dies verbessert die Wartbarkeit der Test-Suite und entspricht der üblichen pytest-Praxis in professionellen Projekten.
Der Test ist synchron, was den Testcode klar und linear macht, obwohl FastAPI intern async arbeitet.
Request- und Response-Validierung¶
FastAPI nutzt Pydantic-Modelle für Eingabe- und Ausgabevalidierung. Tests sollten nicht nur HTTP-Status prüfen, sondern auch die Validierung explizit sicherstellen.
from pydantic import BaseModel
class Item(BaseModel):
item_id: int
name: str
@app.post("/items/")
async def create_item(item: Item):
return item
# tests/test_app.py
from fastapi.testclient import TestClient
def test_create_item_valid() -> None:
payload = {"item_id": 1, "name": "Test Item"}
response = client.post("/items/", json=payload)
assert response.status_code == 200
assert response.json() == payload
def test_create_item_invalid() -> None:
payload = {"item_id": "not-an-int", "name": "Test Item"}
response = client.post("/items/", json=payload)
assert response.status_code == 422 # Unprocessable Entity
Diese Validierungsfehler entsprechen den HTTP-Statuscodes, die FastAPI automatisch generiert.
Überschreiben von Abhängigkeiten und Datenbankzugriffen¶
Für Integrationstests ist das Überschreiben von Abhängigkeiten essenziell, z.B. um echte Datenbanken durch Mock- oder In-Memory-Varianten zu ersetzen.
from fastapi import Depends
from typing import Generator
# Beispiel: Datenbank-Session
def get_db() -> Generator[str, None, None]:
# Hier normalerweise echte DB-Session
yield "real_db_session"
@app.get("/users/")
async def read_users(db=Depends(get_db)):
return {"db": db}
# Test-Override
def override_get_db() -> Generator[str, None, None]:
yield "test_db_session"
app.dependency_overrides[get_db] = override_get_db
def test_read_users_override() -> None:
response = client.get("/users/")
assert response.status_code == 200
assert response.json() == {"db": "test_db_session"}
Das Dependency-Override-Konzept ist ein zentrales idiomatisches Feature von FastAPI
Integrationstests und Fehlerfälle¶
Integrationstests prüfen den gesamten Stack inklusive Middleware, Validierung und Fehlerbehandlung.
def test_not_found() -> None:
response = client.get("/non-existent")
assert response.status_code == 404
def test_openapi_schema() -> None:
response = client.get("/openapi.json")
assert response.status_code == 200
data = response.json()
assert "paths" in data
assert "/items/{item_id}" in data["paths"]
Diese Tests sichern, dass FastAPI-konforme OpenAPI-Dokumentation und Standard-Fehlerantworten korrekt funktionieren.
Professionelle Teststruktur¶
Ein typisches Projektlayout für FastAPI-Tests:
link_shortener/
├── app/
│ ├── main.py
│ ├── api.py
│ ├── dependencies.py
│ └── models.py
└── tests/
├── __init__.py
├── conftest.py
├── test_api.py
└── test_integration.py
conftest.pydefiniert Fixtures wieclientoder Mock-Datenbanken.- Tests in
test_api.pyfokussieren auf einzelne Endpunkte. - Integrationstests in
test_integration.pyprüfen das Zusammenspiel.
Fixture-Beispiel für TestClient¶
import pytest
from fastapi.testclient import TestClient
from app.main import app
@pytest.fixture(scope="module")
def client() -> TestClient:
with TestClient(app) as c:
yield c
def test_example(client: TestClient) -> None:
response = client.get("/items/1")
assert response.status_code == 200
Zusammenfassung¶
TestClienterlaubt synchrone Tests asynchroner FastAPI-Endpunkte, was Testcode klar und idiomatisch macht.- Pydantic-Validierung wird automatisch getestet, inklusive Fehlerfälle mit HTTP 422.
- Dependency Injection über
dependency_overridesermöglicht sauberes Überschreiben von Ressourcen wie Datenbanken. - Integrationstests prüfen OpenAPI-Schema und Fehlerantworten.
- Klare Teststruktur mit
conftest.pyund modularen Testdateien fördert Wartbarkeit.
Diese Konzepte spiegeln die Python-Philosophie wider: explizit, aber pragmatisch, mit Fokus auf Lesbarkeit und Wiederverwendbarkeit.
Praxisprojekt: Test-Driven Development mit pytest¶
Einführung in das Praxisprojekt¶
Im folgenden Praxisprojekt wenden wir Test-Driven Development (TDD) mit pytest an, um eine kleine, aber realistische Anwendung schrittweise zu entwickeln. Dabei lernen Sie den klassischen TDD-Zyklus Red-Green-Refactor kennen und erfahren, wie Sie Tests vor der Implementierung schreiben, um die Anforderungen präzise und wartbar umzusetzen.
Projektidee: URL-Shortener¶
Wir implementieren einen simplen URL-Shortener, der lange URLs in kurze Codes umwandelt und umgekehrt. Diese Anwendung ist überschaubar, aber bietet genug Komplexität, um TDD-Prinzipien, Fixtures, Parametrisierung und Mocking sinnvoll einzusetzen.
Projektstruktur¶
link_shortener/
├── link_shortener/
│ ├── __init__.py
│ └── service.py
└── tests/
├── __init__.py
└── test_link_shortener.py
Schritt 1: Red – Test schreiben, der fehlschlägt¶
Wir beginnen mit einem einfachen Test für die Funktion shorten_url, die eine URL als String entgegennimmt und einen kurzen Code zurückgibt.
# tests/test_link_shortener.py
import pytest
from link_shortener.service import shorten_url
def test_shorten_url_generates_code() -> None:
long_url = "https://example.com/some/long/path"
code = shorten_url(long_url)
assert isinstance(code, str)
assert len(code) > 0
Da die Funktion shorten_url noch nicht existiert, schlägt der Test erwartungsgemäß fehl.
Schritt 2: Green – Implementierung minimaler Code, um Test zu bestehen¶
# link_shortener/service.py
def shorten_url(url: str) -> str:
# Minimalimplementierung, die einen statischen Code zurückgibt
return "abc123"
Der Test läuft nun erfolgreich, allerdings ist die Implementierung noch nicht sinnvoll.
Schritt 3: Refactor – Code verbessern¶
Wir erweitern die Implementierung, um eindeutige Codes zu generieren. Dafür verwenden wir eine einfache Zähler-Logik. Um Seiteneffekte zu vermeiden, kapseln wir den Zustand in einer Klasse.
# link_shortener/service.py
class URLShortener:
def __init__(self) -> None:
self._counter = 0
self._url_map: dict[str, str] = {}
def shorten_url(self, url: str) -> str:
if url in self._url_map:
return self._url_map[url]
self._counter += 1
code = f"code{self._counter}"
self._url_map[url] = code
return code
def resolve_url(self, code: str) -> str | None:
for url, stored_code in self._url_map.items():
if stored_code == code:
return url
return None
Tests anpassen und erweitern¶
# tests/test_link_shortener.py
import pytest
from link_shortener.service import URLShortener
@pytest.fixture
def shortener() -> URLShortener:
return URLShortener()
def test_shorten_url_generates_unique_codes(shortener: URLShortener) -> None:
url1 = "https://example.com/a"
url2 = "https://example.com/b"
code1 = shortener.shorten_url(url1)
code2 = shortener.shorten_url(url2)
assert code1 != code2
def test_resolve_url_returns_original(shortener: URLShortener) -> None:
url = "https://example.com/a"
code = shortener.shorten_url(url)
resolved = shortener.resolve_url(code)
assert resolved == url
def test_resolve_url_returns_none_for_unknown_code(shortener: URLShortener) -> None:
assert shortener.resolve_url("unknown") is None
TDD-Zyklus mit pytest und Fixtures¶
Wir nutzen pytest-Fixtures, um die Testobjekte zu initialisieren und Wiederholungen zu vermeiden. So bleibt der Testcode klar und wartbar.
Parametrisierung für Grenzfälle¶
@pytest.mark.parametrize("url", [
"https://example.com/",
"http://localhost:8000/test",
"https://sub.domain.com/path?query=1",
])
def test_shorten_and_resolve_urls(shortener: URLShortener, url: str) -> None:
code = shortener.shorten_url(url)
assert shortener.resolve_url(code) == url
Mocking und Isolation¶
Für externe Abhängigkeiten (z. B. Datenbanken oder Web-APIs) würden wir monkeypatch oder unittest.mock verwenden. In diesem Beispiel ist das nicht nötig, zeigt aber, wie flexibel pytest ist.
TDD-Prozess als Mermaid-Diagramm¶
flowchart LR
A[Test schreiben]
B[Test fehlgeschlagen]
C[Implementierung]
D[Test erfolgreich]
E[Refactoring]
A --> B
B --> C
C --> D
D --> E
E --> A
Fazit¶
TDD mit pytest fördert präzise, wartbare und getestete Software. Die enge Verzahnung von Tests und Implementierung führt zu robustem Code und reduziert Bugs frühzeitig. Python ermöglicht durch seine dynamische Natur und pytest's Features einen flüssigen Workflow, der sich von statisch typisierten Sprachen durch weniger Boilerplate und mehr Ausdruckskraft unterscheidet.
Nutzen Sie diesen Ansatz, um auch komplexe Projekte mit hoher Testabdeckung und klarer Struktur zu realisieren.