Exceptions und Tests: Kurzeinführung

Diese Kurzeinführung erläutert die Grundlagen von Exception Handling und Testing in Python und dient zur Unterstützung bei der Bearbeitung der Übungsaufgaben. Ein entsprechendes Short-Video wird noch bereitgestellt.

Error Handling mit Exceptions

Motivation

Bei der Entwicklung von Programmen müssen Fehler systematisch erkannt und behandelt werden. Der naive Ansatz, Fehler über Rückgabewerte zu signalisieren, stößt schnell an Grenzen:

# Problematischer Ansatz mit Rückgabewerten
def konto_abheben(kontostand, betrag):
    if betrag > kontostand:
        return -1  # Fehlerwert
    return kontostand - betrag

# Problem: Wie unterscheiden wir echte Kontostände von Fehlercode?
neuer_stand = konto_abheben(100, 200)
if neuer_stand == -1:
    print("Nicht genug Guthaben")
# Was wenn -1 ein gültiger Kontostand sein könnte?

Python bietet mit Exceptions einen eleganten Mechanismus zur Fehlerbehandlung:

def konto_abheben(kontostand, betrag):
    if betrag > kontostand:
        raise ValueError("Nicht genug Guthaben")
    return kontostand - betrag

# Klare Fehlerbehandlung mit try-except
try:
    neuer_stand = konto_abheben(100, 200)
except ValueError as e:
    print(f"Fehler: {e}")

Grundlagen des Exception Handling

Eine Exception unterbricht den normalen Programmfluss und kann an beliebiger Stelle “aufgefangen” werden:

def validate_betrag(betrag):
    if betrag <= 0:
        raise ValueError("Betrag muss positiv sein")
    return betrag

def einzahlung_durchfuehren():
    betrag = validate_betrag(-50)  # Exception wird hier ausgelöst
    print("Wird nie erreicht")

try:
    einzahlung_durchfuehren()  # Exception wird hier gefangen
except ValueError as e:
    print(f"Fehler bei Einzahlung: {e}")

print("Programm läuft weiter")

Testing mit assert und pytest


Von einfachen Tests zu systematischem Testing

Fehler in Programmen zu finden ist einfacher, wenn wir systematisch testen. Ein naiver Ansatz wäre:

# Naive Testmethode mit if-Statements
def konto_anlegen(start_guthaben):
    if start_guthaben < 0:
        raise ValueError("Startguthaben muss positiv sein")
    return {"guthaben": start_guthaben}

# Umständlicher Test
if konto_anlegen(100)["guthaben"] != 100:
    print("Fehler: Konto wurde nicht korrekt angelegt")

try:
    konto_anlegen(-50)
    print("Fehler: Negatives Guthaben wurde akzeptiert")
except ValueError:
    print("OK: Negatives Guthaben wurde abgelehnt")

Python bietet mit assert eine prägnantere Syntax für Tests:

# Bessere Tests mit assert
def test_konto_manuell():
    # Test für positives Guthaben
    konto = konto_anlegen(100)
    assert konto["guthaben"] == 100, "Startguthaben falsch"
    
    # Test für negatives Guthaben
    try:
        konto_anlegen(-50)
        assert False, "Negatives Guthaben wurde akzeptiert"
    except ValueError:
        pass  # Test erfolgreich

Strukturierte Tests mit pytest

pytest vereinfacht das Testen erheblich. Erstellen Sie zwei Dateien im gleichen Verzeichnis. Erstens die Datei mit dem Code, den wir testen wollen:

# bank.py
class Konto:
    def __init__(self, start_guthaben):
        if start_guthaben < 0:
            raise ValueError("Startguthaben muss positiv sein")
        self.guthaben = start_guthaben
    
    def einzahlen(self, betrag):
        if betrag <= 0:
            raise ValueError("Einzahlungsbetrag muss positiv sein")
        self.guthaben += betrag

Zweitens die Datei mit den Tests:

# test_bank.py
from bank import Konto
import pytest

def test_konto_anlegen():
    konto = Konto(100)
    assert konto.guthaben == 100

def test_konto_negativ():
    # Die with-Syntax ist eine kompakte Schreibweise zur Prüfung,
    # ob eine bestimmte Exception ausgelöst wird.
    # Dies ist äquivalent zu:
    #   try:
    #       Konto(-50)
    #       assert False, "ValueError wurde erwartet"
    #   except ValueError:
    #       pass
    with pytest.raises(ValueError):
        Konto(-50)

def test_einzahlung():
    konto = Konto(100)
    konto.einzahlen(50)
    assert konto.guthaben == 150

Die with pytest.raises() Syntax ist eine von pytest bereitgestellte Möglichkeit zu prüfen, ob ein bestimmter Codeblock die erwartete Exception auslöst. Sie ist kürzer und klarer als die entsprechende try-except Konstruktion. Obwohl die with-Syntax für Sie neu ist, ist sie in Python-Tests sehr gebräuchlich und wird Ihnen häufig begegnen.

Die Tests werden dann mit pytest wird über die Kommandozeile ausgeführt:

pytest test_bank.py

Dabei gilt:

  • Testfunktionen müssen mit test_ beginnen
  • Jeder Test sollte einen spezifischen Aspekt prüfen
  • Tests sollten unabhängig voneinander sein
  • pytest findet und führt die Tests automatisch aus

Best Practices für Tests

Testfälle strukturieren

Tests sollten übersichtlich und wartbar sein. Jeder Test sollte einen klar definierten Aspekt prüfen:

# Gute Strukturierung:
def test_konto_anlegen_standard():
    konto = Konto(100)
    assert konto.guthaben == 100

def test_konto_einzahlung_positiv():
    konto = Konto(100)
    konto.einzahlen(50)
    assert konto.guthaben == 150

def test_konto_einzahlung_negativ():
    konto = Konto(100)
    with pytest.raises(ValueError):
        konto.einzahlen(-50)

# Weniger gut - zu viele Aspekte in einem Test:
def test_konto_alles():
    # Zu viele verschiedene Tests in einer Funktion
    konto = Konto(100)
    assert konto.guthaben == 100
    konto.einzahlen(50)
    assert konto.guthaben == 150
    with pytest.raises(ValueError):
        konto.einzahlen(-50)

Grenzfälle testen

Besonders wichtig ist das Testen von Grenzfällen:

  • Standardfälle (normale Werte)
  • Grenzwerte (0, Maximalwerte)
  • Fehlerfälle (ungültige Eingaben)
def test_konto_grenzfaelle():
    # Minimaler erlaubter Wert
    konto = Konto(0)
    assert konto.guthaben == 0
    
    # Sehr große Werte
    konto_gross = Konto(1000000)
    assert konto_gross.guthaben == 1000000
    
    # Einzahlung von 0
    with pytest.raises(ValueError):
        konto.einzahlen(0)

pytest Ausführung

Die wichtigsten pytest Kommandos:

# Alle Tests im aktuellen Verzeichnis
pytest

# Bestimmte Testdatei
pytest test_bank.py

# Ausführliche Ausgabe
pytest -v

# Bei erstem Fehler stoppen
pytest -x

pytest zeigt bei Fehlern hilfreiche Details:

===== FAILURES =====
def test_konto_einzahlung_positiv():
    konto = Konto(100)
    konto.einzahlen(50)
>   assert konto.guthaben == 150
E   assert 149 == 150

Debugging in VS Code

Die CS50-Entwicklungsumgebung unterstützt beim systematischen Finden von Fehlern (Debugging). Wie bereits aus C bekannt, können Sie dafür den integrierten Debugger nutzen:

debug50 python myscript.py

Der Debugger ermöglicht:

  • Setzen von Breakpoints durch Klick auf die Zeilennummer
  • Schrittweises Durchlaufen des Codes
  • Inspektion von Variablenwerten
  • Beobachten des Call Stacks
# Beispiel für Code, den wir debuggen möchten
def process_konto(start_guthaben, einzahlungen):
    konto = Konto(start_guthaben)  # Breakpoint hier setzen
    for betrag in einzahlungen:
        konto.einzahlen(betrag)    # Werte schrittweise prüfen
    return konto.guthaben

# Bei diesem Aufruf können wir die Werte beobachten
result = process_konto(100, [50, -30, 20])

Systematisches Debuggen hilft dabei:

  • Den Programmablauf zu verstehen
  • Fehlerhafte Zustände zu erkennen
  • Die Ursache von Exceptions zu finden

Details zur Nutzung des Debuggers haben wir uns bereits bei C angeschaut.