Skip to content

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:

uv add pytest --dev

Erste Testdateien und Namenskonventionen

pytest erkennt Testmodule und Testfunktionen anhand von Namensmustern:

  • Dateien: test_*.py oder *_test.py
  • Funktionen: test_*
  • Klassen: Test* (ohne __init__)

Beispiel Projektstruktur:

link_shortener/
├── app.py
└── test_app.py

In test_app.py:

def test_addition():
    assert 1 + 1 == 2

Tests ausführen mit uv

Tests werden im Projektverzeichnis mit

uv run -m pytest

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:

x = 3
assert x == 5

Fehlermeldung:

>       assert x == 5
E       assert 3 == 5

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:

link_shortener/
├── app.py
├── conftest.py
├── test_api.py
└── test_utils.py

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.

# conftest.py
import pytest

@pytest.fixture
def api_client() -> str:
    return "Client-Instanz"
# test_api.py

def test_endpoint(api_client: str) -> None:
    assert api_client == "Client-Instanz"

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.py für projektweite, wiederverwendbare Fixtures.
  • Wähle den Fixture-Scope bewusst, um Balance zwischen Isolation und Performance zu erreichen.
  • Nutze yield fü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

  • monkeypatch ist ein mächtiges Werkzeug, um externe Abhängigkeiten temporär zu ersetzen und kontrollierte Testumgebungen zu schaffen.
  • unittest.mock bietet 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.py definiert Fixtures wie client oder Mock-Datenbanken.
  • Tests in test_api.py fokussieren auf einzelne Endpunkte.
  • Integrationstests in test_integration.py prü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

  • TestClient erlaubt 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_overrides ermöglicht sauberes Überschreiben von Ressourcen wie Datenbanken.
  • Integrationstests prüfen OpenAPI-Schema und Fehlerantworten.
  • Klare Teststruktur mit conftest.py und 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.