Home

03 Grundlagen des Testens: Boundary Analysis und LLM-Assisted Testing

lecture testing pytest unit-testing test-pyramid equivalence-classes boundary-testing llm-assisted-development

1. Einführung: Willkommen zurück zum Testing - Von der Theorie zur Praxis

Willkommen zurück! In Teil 1 von Kapitel 03 (Grundlagen des Testens) haben Sie die Grundlagen des Software-Testings gelernt:

Was wir in Teil 1 behandelt haben:

  1. Warum Testing wichtig ist
    • Code-Qualität (Ruff, Pyright) ≠ Code-Korrektheit (Tests)
    • CI kann grün sein, aber Ihr Code kann trotzdem kritische Bugs haben
    • Tests fangen Logikfehler, die statische Analysetools übersehen
  2. Die Testing-Pyramide
    • 70% Unit-Tests (schnell, fokussiert, günstig)
    • 20% Integrationstests (mittlere Geschwindigkeit)
    • 10% E2E-Tests (langsam, teuer)
    • Nicht das invertierte “Test-Cone” Anti-Pattern!
  3. Unit-Testing-Grundlagen
    • AAA-Pattern: Arrange-Act-Assert
    • Warum pytest der Industriestandard ist
    • Ihren ersten Unit-Test schreiben
    • Die Unmöglichkeit von exhaustivem Testing
  4. Clean-Code-Prinzipien für Testing
    • Ein Konzept pro Test
    • Eine Assertion pro Test (mit pragmatischen Ausnahmen)
    • Beschreibende Testnamen, die Ihnen sagen, was kaputt ist
  5. Äquivalenzklassen
    • Einfache Floats → 5 Äquivalenzklassen
    • Mehrere Parameter → 10+ Klassen (Komplexitätsexplosion!)
    • Array-Eingaben → Strukturelle + Wert-Dimensionen
    • Komplexe Funktionen wie find_intersection() → 600+ potenzielle Kombinationen!

Die Herausforderung, die wir Ihnen hinterlassen haben:

Sie wissen jetzt, WARUM Testing wichtig ist und WIE man grundlegende Unit-Tests schreibt. Aber Sie denken wahrscheinlich:

Die Mission heute: Von Testing-Grundlagen zur Testing-Meisterschaft

In Teil 2 werden wir diese Probleme mit drei mächtigen Techniken lösen:

  1. Boundary Value Analysis - Wo Bugs sich tatsächlich verstecken (nicht in der Mitte von Äquivalenzklassen!)
  2. LLM-Assisted Testing - Den “Test Cone” durchbrechen, indem wir KI für Boilerplate nutzen (mit menschlicher Aufsicht)
  3. Integration Testing & Workflow - Testing zu einem natürlichen Teil deines Entwicklungsprozesses machen

Was Teil 2 anders macht:

Teil 1 war konzeptionell - verstehen, was Tests sind und warum sie wichtig sind.

Teil 2 ist praktisch - spezifische Techniken lernen, um bessere Tests schneller zu schreiben und Testing in Ihren echten Workflow zu integrieren.

Am Ende der heutigen Vorlesung werden Sie in der Lage sein:

Der Weg voraus:

✅ Teil 1: Fundament (Warum testen? Wie schreibt man grundlegende Unit-Tests?)
→ Teil 2: Meisterschaft (Wo verstecken sich Bugs? Wie testet man effizient?)
→ Kapitel 03 (TDD und CI): TDD & CI (Tests ZUERST schreiben, alles automatisieren)

Lass uns in die Techniken eintauchen, die Anfänger von Profis unterscheiden: Boundary Value Analysis und LLM-Assisted Testing!


2. Boundary Value Analysis: Wo sich Bugs verstecken

Beobachtung: Bugs lauern oft an den Grenzen zwischen Äquivalenzklassen.

Warum Grenzen? Off-by-one-Fehler, Gleitkomma-Präzision, Edge Cases in bedingter Logik.

Lass uns Boundary Analysis auf alle unsere Beispiele anwenden.


2.1 Grenzen für das einfache Float-Beispiel: reciprocal(x)

Funktion: reciprocal(x) = 1/x

Äquivalenzklassen und ihre Grenzen:

Äquivalenzklasse Zu testende Grenzwerte
Positive Zahlen x = 0.0001 (nahe Null), x = 1.0, x = 1000000.0 (sehr groß)
Negative Zahlen x = -0.0001 (nahe Null), x = -1.0, x = -1000000.0
Null x = 0.0, x = 1e-100 (extrem nahe an Null)
Extreme Grenzen sys.float_info.max, sys.float_info.min, float('inf'), float('nan')

Python Float-Grenzen (Ähnlich wie C/C++ FLT_MAX, DBL_MIN):

Python-Floats sind 64-Bit IEEE 754 Doubles.

Was ist IEEE 754?

IEEE 754 ist der internationale Standard für Gleitkomma-Arithmetik, der definiert, wie Computer Dezimalzahlen darstellen und berechnen. Er spezifiziert:

Warum das für Testing wichtig ist: Das Verständnis von IEEE 754-Grenzen hilft Ihnen, Tests für Edge Cases zu schreiben, die an den Grenzen numerischer Präzision auftreten. Alle modernen Programmiersprachen (Python, C, C++, Java, JavaScript) folgen diesem Standard.

Offizielle Referenzen:

Python stellt das sys.float_info-Modul bereit, um auf plattformspezifische Float-Limits zuzugreifen, die durch IEEE 754 definiert sind:

Python-Konstante C/C++-Äquivalent Typischer Wert Beschreibung
sys.float_info.max DBL_MAX 1.7976931348623157e+308 Maximal darstellbarer endlicher Float
sys.float_info.min DBL_MIN 2.2250738585072014e-308 Minimal positiver normalisierter Float
sys.float_info.epsilon DBL_EPSILON 2.220446049250313e-16 Differenz zwischen 1.0 und dem nächsten darstellbaren Float
math.inf oder float('inf') INFINITY inf Positive Unendlich (nicht-endlicher Wert)
-math.inf oder float('-inf') -INFINITY -inf Negative Unendlich (nicht-endlicher Wert)
math.nan oder float('nan') NAN nan Not a Number (undefiniertes Ergebnis)
math.ulp(0.0) ≈ 5e-324 Kleinster positiver subnormaler (denormalisierter) Float
math.ulp(x) Variiert Unit in the Last Place (Abstand zum nächsten darstellbaren Float)

Kritischer Unterschied: math.inf vs. sys.float_info.max

Diese sind fundamental verschieden und testen verschiedene Aspekte deines Codes:

  1. math.inf (oder float('inf')) - IEEE-754 Positive Unendlich
    • KEINE Zahl, die man in endlicher Mantisse/Exponent speichern kann
    • Ein spezieller Wert, der größer ist als jeder endliche Float
    • Propagiert durch viele Operationen: inf + 1 = inf, inf * 2 = inf
    • Ergebnis eines Overflows: sys.float_info.max * 2 = inf
    • Nutzen zum Testen: Wie Ihr Code Unendlich behandelt (Sentinel-Logik, Clamping, Division-durch-Null-Ergebnisse)
  2. sys.float_info.max (≈ 1.8e308) - Größter endlicher Float
    • Der größte endliche IEEE-754 Double
    • Die “obere Grenze” bevor Sie zu Unendlich überlaufen
    • Nutze zum Testen: Endliche Bereichsgrenzen (Overflow/Underflow-Kanten)

Beispiel zur Veranschaulichung des Unterschieds:

import sys
import math

# Diese sind NICHT dasselbe!
print(math.inf > sys.float_info.max)  # True - Unendlich ist größer als jeder endliche Wert
print(sys.float_info.max * 2)         # inf - Overflow!
print(1 / math.inf)                   # 0.0 - Kehrwert von Unendlich
print(1 / sys.float_info.max)         # ≈ 5.6e-309 - winzig aber endlich!

Wichtige Unterscheidungen:

Andere nützliche endliche Grenzen (oft übersehen):

Grenztyp Python-Ausdruck Typischer Wert Wann testen
Kleinster positiver normalisierter sys.float_info.min ≈ 2.2e-308 Testen von Underflow-Schwellwerten, relativen Fehlern nahe Null
Kleinster positiver subnormaler math.ulp(0.0) ≈ 5e-324 Fangen von Denormal-Behandlung, Flush-to-Zero-Überraschungen
Unit in Last Place (ULP) math.ulp(x) Variiert nach x Prüfen "nächster darstellbarer" Werte in präzisionssensitivem Code
Nächster Float in Richtung math.nextafter(x, y) Variiert Haarscharf-Grenzen (z.B. Schritt von max zu Unendlich)
Negativster endlicher -sys.float_info.max ≈ -1.8e308 Untere Grenze vor Underflow zu -inf

Praktische Anleitung: Welches sollte ich in Tests verwenden?

Um Unendlich-Behandlung zu testen (nicht-endliche Werte):

import math

# Nutze math.inf (bevorzugt für Lesbarkeit)
def test_reciprocal_handles_infinity():
    result = reciprocal(math.inf)
    assert result == 0.0  # 1/inf = 0

Um endliche Bereichsgrenzen zu testen (Overflow/Underflow-Kanten):

import sys

def test_reciprocal_handles_max_float():
    result = reciprocal(sys.float_info.max)
    # Kehrwert des größten endlichen → extrem klein
    assert result > 0
    assert result < sys.float_info.min  # Könnte zu subnormal underflowlen

Um Präzisionsgrenzen zu testen (ULP-Testing):

import math

def test_reciprocal_precision_at_boundary():
    # Teste den "nächsten" darstellbaren Wert nach 1.0
    x = math.nextafter(1.0, 2.0)  # Etwas größer als 1.0
    result = reciprocal(x)
    # Sollte etwas kleiner als 1.0 sein
    assert result < 1.0

Tipp für NumPy-Benutzer:

Wenn Ihr Code NumPy-Arrays verwendet, nutzen Sie np.finfo() für Konsistenz mit dem dtype unter Test:

import numpy as np

# Für float64 (Python float-Äquivalent)
fi = np.finfo(np.float64)
print(fi.max)        # Größter endlicher (≈ 1.8e308)
print(fi.tiny)       # Kleinster positiver normalisierter (≈ 2.2e-308)
print(fi.eps)        # Maschinen-Epsilon (≈ 2.2e-16)

# Nutze mit nextafter
x_next_to_max = np.nextafter(fi.max, np.inf)  # → inf

# Für float32 (falls mit einfacher Präzision gearbeitet wird)
fi32 = np.finfo(np.float32)
print(fi32.max)      # ≈ 3.4e38 (viel kleiner als float64!)

Das hält alles konsistent mit dem NumPy-dtype, den Sie tatsächlich verwenden.

Umfassende Boundary-Test-Suite:

import sys
import math
import pytest

# Konstanten für Lesbarkeit definieren (Best Practices folgend)
INF = math.inf
NINF = -math.inf
NAN = math.nan
FMAX = sys.float_info.max
FMIN_NORM_POS = sys.float_info.min
FMIN_SUB_POS = math.ulp(0.0)
EPSILON = sys.float_info.epsilon


class TestReciprocalBoundaries:
    """Umfassende Boundary-Tests für die reciprocal(x) Funktion."""

    # GRUNDLEGENDE BOUNDARIES (nahe Null, typische Werte)

    def test_reciprocal_boundary_near_zero_positive(self):
        """Boundary: Sehr kleine positive Zahl"""
        result = reciprocal(1e-6)  # 0.000001
        assert result == pytest.approx(1e6, rel=1e-9)  # Sollte 1000000 sein

    def test_reciprocal_boundary_near_zero_negative(self):
        """Boundary: Sehr kleine negative Zahl"""
        result = reciprocal(-1e-6)
        assert result == pytest.approx(-1e6, rel=1e-9)

    def test_reciprocal_boundary_exactly_zero(self):
        """Boundary: Exakt Null (sollte Exception werfen)"""
        with pytest.raises(ZeroDivisionError):
            reciprocal(0.0)

    def test_reciprocal_boundary_very_large_positive(self):
        """Boundary: Sehr große positive Zahl"""
        result = reciprocal(1e10)
        assert result == pytest.approx(1e-10, rel=1e-9)

    # ENDLICHE BEREICHSGRENZEN (sys.float_info Limits)

    def test_reciprocal_boundary_max_finite_float(self):
        """Boundary: Größter endlicher Float (sys.float_info.max ≈ 1.8e308)

        Testet Overflow-Prävention an der oberen endlichen Grenze.
        Kehrwert des größten endlichen → extrem klein (kann zu subnormal underflowlen).
        """
        result = reciprocal(FMAX)
        assert result > 0, "Kehrwert des max float sollte positiv sein"
        assert result < FMIN_NORM_POS, "Ergebnis underflowt unter den normalisierten Bereich (subnormal)"
        # Ergebnis ist ca. 5.6e-309 (subnormal/denormalisiert)

    def test_reciprocal_boundary_min_normalized_float(self):
        """Boundary: Kleinster positiver normalisierter Float (sys.float_info.min ≈ 2.2e-308)

        Testet Underflow-zu-Overflow-Übergang.
        Kehrwert des min normalisierten → extrem groß (overflowt zu Unendlich).
        """
        result = reciprocal(FMIN_NORM_POS)
        assert result == INF, "Kehrwert des min normalisierten float overflowt zu Unendlich"

    def test_reciprocal_boundary_min_subnormal_float(self):
        """Boundary: Kleinster positiver subnormaler Float (math.ulp(0.0) ≈ 5e-324)

        Testet Handling denormalisierter Zahlen.
        Kehrwert des kleinsten darstellbaren → massiver Overflow zu Unendlich.
        """
        result = reciprocal(FMIN_SUB_POS)
        assert result == INF, "Kehrwert des min subnormal overflowt zu Unendlich"

    def test_reciprocal_boundary_epsilon(self):
        """Boundary: Maschinen-Epsilon (sys.float_info.epsilon ≈ 2.2e-16)

        Testet Präzisionslimit-Verhalten.
        Epsilon ist der kleinste Wert, bei dem 1.0 + epsilon != 1.0
        """
        result = reciprocal(EPSILON)
        expected = 1.0 / EPSILON  # ≈ 4.5e15
        assert result == pytest.approx(expected, rel=1e-9)

    def test_reciprocal_boundary_most_negative_finite(self):
        """Boundary: Negativster endlicher Float (-sys.float_info.max ≈ -1.8e308)

        Testet untere endliche Grenze (es gibt kein sys.float_info.min_negative).
        """
        result = reciprocal(-FMAX)
        assert result < 0, "Kehrwert des negativsten endlichen sollte negativ sein"
        assert result > -FMIN_NORM_POS, "Ergebnis ist winzig negativ (subnormaler Bereich)"

    # NICHT-ENDLICHE BOUNDARIES (Unendlichkeiten und NaN)

    def test_reciprocal_boundary_positive_infinity(self):
        """Boundary: Positive Unendlichkeit (nicht-endlicher Wert)

        Testet Spezialwert-Handling (nicht Overflow-Prävention).
        1 / inf = 0 (mathematischer Grenzwert)
        """
        result = reciprocal(INF)
        assert result == 0.0, "Kehrwert von positiver Unendlichkeit sollte Null sein"

    def test_reciprocal_boundary_negative_infinity(self):
        """Boundary: Negative Unendlichkeit (nicht-endlicher Wert)

        1 / -inf = -0 (negative Null)
        """
        result = reciprocal(NINF)
        # Python behandelt 0.0 == -0.0 als True (IEEE 754 Verhalten)
        assert result == 0.0 or result == -0.0, "Kehrwert von -inf sollte Null sein"

    def test_reciprocal_boundary_nan(self):
        """Boundary: Not a Number (undefinierter/ungültiger Input)

        NaN propagiert durch Operationen (crasht nicht).
        Wichtig: nan == nan ist immer False! Nutze math.isnan().
        """
        result = reciprocal(NAN)
        assert math.isnan(result), "Kehrwert von NaN sollte NaN sein (propagiert)"

    # HAARSCHARF-BOUNDARIES (mit math.nextafter)

    def test_reciprocal_boundary_next_after_max_toward_inf(self):
        """Boundary: Nächster Float nach max (Richtung Unendlich)

        Nutzt math.nextafter() um den exakten Übergangspunkt zu testen.
        Nächster Float nach FMAX Richtung Unendlich IST Unendlich.
        """
        x_after_max = math.nextafter(FMAX, INF)
        assert x_after_max == INF, "Nächster Float nach max Richtung inf ist Unendlich"
        result = reciprocal(x_after_max)
        assert result == 0.0, "Kehrwert von inf ist 0"

    def test_reciprocal_boundary_next_after_one_toward_two(self):
        """Boundary: Nächster darstellbarer Float nach 1.0

        Testet ULP (Unit in Last Place) Sensitivität.
        1.0 + epsilon ist der nächste darstellbare Wert nach 1.0
        """
        x_just_above_one = math.nextafter(1.0, 2.0)
        result = reciprocal(x_just_above_one)
        # Kehrwert sollte leicht kleiner als 1.0 sein
        assert result < 1.0, f"Erwartet < 1.0, bekam {result}"
        assert result == pytest.approx(1.0, rel=1e-14), "Sollte sehr nahe an 1.0 sein"

    def test_reciprocal_boundary_ulp_sensitivity(self):
        """Boundary: Testing ULP (Unit in Last Place) um einen Wert herum

        Demonstriert Gleitkomma-Granularität.
        """
        x = 1000.0
        ulp_at_x = math.ulp(x)  # Abstand bei 1000.0

        result1 = reciprocal(x)
        result2 = reciprocal(x + ulp_at_x)  # Nächster darstellbarer Wert

        assert result1 != result2, "Benachbarte Floats sollten unterschiedliche Kehrwerte produzieren"
        assert result1 > result2, "Größerer Input → kleinerer Kehrwert"

pytest’s @pytest.mark.parametrize verwenden - Test-Duplikation vermeiden

Bisher haben wir separate Testfunktionen für jede Boundary geschrieben. Aber was ist, wenn Sie dieselbe Logik mit mehreren Eingabewerten testen müssen?

Das Problem: Repetitive Tests

# ❌ MÜHSAM: 6 fast-identische Tests schreiben
def test_reciprocal_max_float():
    result = reciprocal(FMAX)
    assert result > 0 and result < FMIN_NORM_POS

def test_reciprocal_negative_max_float():
    result = reciprocal(-FMAX)
    assert result < 0 and result > -FMIN_NORM_POS

def test_reciprocal_min_norm_float():
    result = reciprocal(FMIN_NORM_POS)
    assert result == INF

# ... 3 weitere ähnliche Tests ...

Probleme mit diesem Ansatz:

Die Lösung: Parametrisierte Tests

pytest bietet @pytest.mark.parametrize an, um dieselbe Testfunktion mit unterschiedlichen Eingabedaten auszuführen:

# ✅ SAUBER: Eine Testfunktion, mehrere Inputs
@pytest.mark.parametrize("x, expected_behavior", [
    (FMAX, "underflow to subnormal"),
    (-FMAX, "underflow to negative subnormal"),
    (FMIN_NORM_POS, "overflow to infinity"),
    (FMIN_SUB_POS, "overflow to infinity"),
    (INF, "exact zero"),
    (NINF, "exact zero"),
])
def test_reciprocal_extreme_boundaries_summary(x, expected_behavior):
    """Parametrisierter Test für alle extremen Boundaries mit Beschreibungen."""
    result = reciprocal(x)

    if "subnormal" in expected_behavior:
        assert math.isfinite(result), f"{expected_behavior}: Ergebnis sollte endlich sein"
        assert abs(result) < FMIN_NORM_POS, f"{expected_behavior}: sollte im subnormalen Bereich sein"
    elif "overflow to infinity" in expected_behavior:
        assert result == INF, f"{expected_behavior}: sollte zu Unendlich overflowlen"
    elif "exact zero" in expected_behavior:
        assert result == 0.0, f"{expected_behavior}: sollte exakt Null sein"

Wie es funktioniert:

  1. Decorator-Syntax: @pytest.mark.parametrize("param1, param2", [...])
    • Erstes Argument: Parameternamen (komma-separierter String)
    • Zweites Argument: Liste von Tupeln (ein Tupel pro Testfall)
  2. Testfunktion empfängt Parameter: def test_function(param1, param2):
    • pytest injiziert die Werte jedes Tupels in die Funktion
  3. pytest führt den Test mehrmals aus: Einmal pro Tupel in der Liste
    • Jede Ausführung wird als separater Test behandelt
    • Test-Output zeigt, welche Kombination passed/failed

Beispiel pytest Output:

$ pytest test_boundaries.py::test_reciprocal_extreme_boundaries_summary -v

test_boundaries.py::test_reciprocal_extreme_boundaries_summary[FMAX-underflow to subnormal] PASSED
test_boundaries.py::test_reciprocal_extreme_boundaries_summary[-FMAX-underflow to negative subnormal] PASSED
test_boundaries.py::test_reciprocal_extreme_boundaries_summary[FMIN_NORM_POS-overflow to infinity] PASSED
test_boundaries.py::test_reciprocal_extreme_boundaries_summary[FMIN_SUB_POS-overflow to infinity] PASSED
test_boundaries.py::test_reciprocal_extreme_boundaries_summary[INF-exact zero] PASSED
test_boundaries.py::test_reciprocal_extreme_boundaries_summary[NINF-exact zero] PASSED

================================== 6 passed in 0.03s ==================================

Hauptvorteile:

Wann parametrize verwenden:

BENUTZE wenn:

BENUTZE NICHT wenn:

Weitere Beispiele:

# Mehrere Äquivalenzklassen mit gleicher Validierung testen
@pytest.mark.parametrize("angle", [-10, -20, -30, -45])
def test_find_intersection_downward_angles_all_succeed(angle):
    """Teste, dass alle Abwärtswinkel Intersection finden"""
    x, y, dist = find_intersection(x_road, y_road, angle)
    assert x is not None
    assert dist > 0

# Boundary-Werte mit erwarteten Ergebnissen testen
@pytest.mark.parametrize("x, expected", [
    (1.0, 1.0),
    (2.0, 0.5),
    (0.5, 2.0),
    (10.0, 0.1),
])
def test_reciprocal_normal_values(x, expected):
    """Teste reciprocal mit normalen Werten"""
    result = reciprocal(x)
    assert result == pytest.approx(expected, rel=1e-9)

Vollständiger parametrisierter Test für unsere extremen Boundaries:

# PARAMETRISIERTER TEST (pytest-Feature für mehrere ähnliche Tests)

@pytest.mark.parametrize("x, expected_behavior", [
    (FMAX, "underflow to subnormal"),
    (-FMAX, "underflow to negative subnormal"),
    (FMIN_NORM_POS, "overflow to infinity"),
    (FMIN_SUB_POS, "overflow to infinity"),
    (INF, "exact zero"),
    (NINF, "exact zero"),
])
def test_reciprocal_extreme_boundaries_summary(x, expected_behavior):
    """Parametrisierter Test für alle extremen Boundaries mit Beschreibungen."""
    result = reciprocal(x)

    if "subnormal" in expected_behavior:
        assert math.isfinite(result), f"{expected_behavior}: Ergebnis sollte endlich sein"
        assert abs(result) < FMIN_NORM_POS, f"{expected_behavior}: sollte im subnormalen Bereich sein"
    elif "overflow to infinity" in expected_behavior:
        assert result == INF, f"{expected_behavior}: sollte zu Unendlich overflowlen"
    elif "exact zero" in expected_behavior:
        assert result == 0.0, f"{expected_behavior}: sollte exakt Null sein"

Warum diese extremen Boundaries testen?

Boundary-Typ Was es testet Real-World-Szenario
sys.float_info.max Endliche Overflow-Prävention Astronomische Distanzen, Kosmologie-Berechnungen
sys.float_info.min Underflow-zu-Overflow-Übergang Quantenphysik, Partikelsimulationen
math.ulp(0.0) Subnormal/Denormal-Handling Hochpräzises wissenschaftliches Computing
sys.float_info.epsilon Präzisionslimits nahe 1.0 Finanzberechnungen, numerische Stabilität
math.inf Nicht-endliches Wert-Handling Division-durch-Null-Ergebnisse, Sentinel-Werte
math.nan Ungültige Input-Propagation Fehlende Daten in Datasets, undefinierte Operationen
math.nextafter() Haarscharf-Übergänge Exaktes Boundary-Verhalten verifizieren

Wichtige Erkenntnisse:

Häufiger Fehler:

# ❌ FALSCH: Nur Unendlichkeit testen
def test_reciprocal_large_values():
    result = reciprocal(float('inf'))
    assert result == 0.0

# Problem: Dies testet nicht endlichen Overflow (sys.float_info.max)!
# Eine Funktion könnte inf korrekt handhaben, aber bei FMAX crashen.
# ✅ KORREKT: Teste SOWOHL endliche ALS AUCH nicht-endliche Boundaries
def test_reciprocal_max_finite():
    result = reciprocal(sys.float_info.max)  # Endlich
    assert result < sys.float_info.min  # Underflow-Verhalten

def test_reciprocal_infinity():
    result = reciprocal(math.inf)  # Nicht-endlich
    assert result == 0.0  # Spezialwert-Handling

2.2 Boundaries für Multi-Parameter: reciprocal_sum(x, y, z)

Funktion: reciprocal_sum(x, y, z) = 1/(x+y+z)

Zu testende Boundaries:

Boundary-Bedingung Testwerte Warum wichtig
Summe exakt Null (1.0, -0.5, -0.5) Division durch Null
Summe nahe Null (positiv) (1.0, -0.9999, 0.0) Großes positives Ergebnis
Summe nahe Null (negativ) (-1.0, 0.9999, 0.0) Großes negatives Ergebnis
Ein Parameter Null (0.0, 1.0, 1.0) Beeinflusst Summe nicht
Zwei Parameter Null (0.0, 0.0, 2.0) Nur einer zählt
Alle Parameter Null (0.0, 0.0, 0.0) Division durch Null

Boundary-Tests:

def test_reciprocal_sum_boundary_sum_exactly_zero():
    """Boundary: Summe wird exakt zu Null"""
    with pytest.raises(ZeroDivisionError):
        reciprocal_sum(1.0, -0.5, -0.5)

def test_reciprocal_sum_boundary_sum_near_zero_positive():
    """Boundary: Summe ist sehr klein positiv"""
    result = reciprocal_sum(1.0, -0.9999, 0.0)  # sum = 0.0001
    assert result == pytest.approx(10000.0, rel=1e-5)

def test_reciprocal_sum_boundary_all_zeros():
    """Boundary: Alle Parameter Null"""
    with pytest.raises(ZeroDivisionError):
        reciprocal_sum(0.0, 0.0, 0.0)

def test_reciprocal_sum_boundary_two_zeros():
    """Boundary: Zwei Parameter Null, einer nicht-Null"""
    result = reciprocal_sum(0.0, 0.0, 0.5)  # sum = 0.5
    assert result == pytest.approx(2.0, rel=1e-10)

2.3 Boundaries für Array-Beispiel: array_sum(arr)

Funktion: array_sum(arr) - Summe der Array-Elemente

Strukturelle Boundaries:

Boundary Testfall
Leeres Array len(arr) = 0
Einzelnes Element len(arr) = 1
Zwei Elemente len(arr) = 2 (kleinster nicht-trivialer Fall)

Wert-Boundaries:

Boundary Testfall
Alle Nullen [0.0, 0.0, 0.0]
Enthält eine Null [0.0, 1.0, 2.0]
Alle gleicher Wert [5.0, 5.0, 5.0]
Alternierende Vorzeichen nahe Null [1e-10, -1e-10, 1e-10]

Boundary-Tests:

def test_array_sum_boundary_empty_array():
    """Boundary: Leeres Array (Länge = 0)"""
    with pytest.raises(ValueError):
        array_sum(np.array([]))

def test_array_sum_boundary_single_element():
    """Boundary: Einzelnes Element (Länge = 1)"""
    result = array_sum(np.array([5.0]))
    assert result == 5.0

def test_array_sum_boundary_two_elements():
    """Boundary: Zwei Elemente (minimal nicht-trivial)"""
    result = array_sum(np.array([3.0, 4.0]))
    assert result == 7.0

def test_array_sum_boundary_all_zeros():
    """Boundary: Alle Elemente sind Null"""
    result = array_sum(np.array([0.0, 0.0, 0.0]))
    assert result == 0.0

def test_array_sum_boundary_alternating_near_zero():
    """Boundary: Werte nahe Null mit alternierenden Vorzeichen"""
    result = array_sum(np.array([1e-10, -1e-10, 1e-10]))
    assert abs(result - 1e-10) < 1e-15  # Summe sollte nahe 1e-10 sein

2.3.1 Was ist mit großen Arrays?

Gute Frage! Sie fragen sich vielleicht: “Wir haben 0, 1, 2 Elemente getestet… aber was ist mit 1000 oder 1.000.000 Elementen?”

Die kurze Antwort: Unit-Tests sollten generell kleine, repräsentative Arrays verwenden (typischerweise 10-100 Elemente), keine massiven. Große Arrays gehören in Performance-Tests, nicht in Unit-Tests.

Warum nicht mit riesigen Arrays in Unit-Tests testen?

Problem Auswirkung Beispiel
Langsame Tests Unit-Tests sollten in Millisekunden laufen. Große Arrays verlangsamen sie auf Sekunden. Array mit 1 Million → 100x langsamere Testsuite
Findet nicht mehr Bugs Wenn Ihr Code mit 10 Elementen funktioniert, funktioniert er meist auch mit 10.000. Summierungslogik ändert sich nicht mit Größe
Rauschen im Test-Output Schwer zu debuggen mit massiven Daten "Array mismatch at index 47,293" - viel Glück!
Speichernutzung CI-Server könnten out of memory gehen 10 Tests × 1M Floats × 8 Bytes = 80 MB pro Test-Durchlauf

Wann BRAUCHEN Sie große Arrays?

TESTE große Arrays wenn:

  1. Algorithmus-Komplexität wichtig ist (z.B. O(n²) vs O(n))
    def test_sort_performance_scales_linearly():
        """Verifiziere, dass Sortierung nicht zu O(n²) degradiert"""
        import time
    
        # Kleines Array
        arr_small = np.random.rand(100)
        start = time.time()
        sort_function(arr_small)
        time_small = time.time() - start
    
        # Großes Array (10x größer)
        arr_large = np.random.rand(1000)
        start = time.time()
        sort_function(arr_large)
        time_large = time.time() - start
    
        # O(n log n) sollte ~13x langsamer sein (10 * log₂(10) ≈ 13)
        # NICHT 100x langsamer (das wäre O(n²))
        assert time_large < time_small * 20, "Sortierung scheint O(n²) zu sein!"
    
  2. Speicher-Allokations-Bugs (Buffer-Overflows, Off-by-One bei spezifischen Größen)
    def test_array_processing_handles_power_of_two_sizes():
        """Teste Größen wie 256, 512, 1024 (häufige Buffer-Boundaries)"""
        for size in [256, 512, 1024, 2048]:
            arr = np.random.rand(size)
            result = process_array(arr)
            assert len(result) == size, f"Fehlgeschlagen bei Größe {size}"
    
  3. Regressionstest für einen spezifischen Bug der nur mit großen Daten auftrat
    def test_regression_issue_42_overflow_at_10000_elements():
        """Regression: Integer Overflow trat bei exakt 10.000 Elementen auf
    
        Bug-Report: https://github.com/yourproject/issues/42
        Gefixt durch Umstellung von int32 auf int64 Akkumulator
        """
        arr = np.ones(10_000)  # Spezifische Größe die den Bug verursachte
        result = array_sum(arr)
        assert result == 10_000.0, "Overflow-Regression erkannt!"
    

Best Practice: Separate Performance-Tests

# tests/test_geometry.py (Unit-Tests - SCHNELL)
def test_find_intersection_basic():
    """Unit-Test: Kleines repräsentatives Array"""
    x_road = np.array([0, 10, 20])  # Nur 3 Punkte
    y_road = np.array([0, 2, 4])
    x, y, dist = find_intersection(x_road, y_road, -10.0)
    assert x is not None

# tests/test_geometry_performance.py (Performance-Tests - LANGSAM)
@pytest.mark.slow  # Markiere als langsam, damit wir während Entwicklung skippen können
def test_find_intersection_large_road():
    """Performance-Test: Realistischer großer Road-Datensatz"""
    x_road = np.linspace(0, 1000, 10_000)  # 10.000 Punkte
    y_road = np.sin(x_road / 10) * 5

    import time
    start = time.time()
    x, y, dist = find_intersection(x_road, y_road, -10.0)
    elapsed = time.time() - start

    assert x is not None
    assert elapsed < 0.1, f"Zu langsam: {elapsed:.3f}s für 10k Punkte"

Performance-Tests separat ausführen:

# Normale Entwicklung: Langsame Tests skippen (läuft in Sekunden)
$ pytest tests/ -v

# Vor PR-Merge: Alle Tests inkl. langsame ausführen (läuft in Minuten)
$ pytest tests/ -v --run-slow

# Nur Performance-Tests
$ pytest tests/test_geometry_performance.py -v

Property-Based Testing mit Hypothesis (Fortgeschritten)

Für gründliches Testen mit variierenden Größen, nutze Hypothesis:

from hypothesis import given, strategies as st

@given(st.lists(st.floats(allow_nan=False, allow_infinity=False),
                min_size=0, max_size=100))
def test_array_sum_any_valid_array(arr):
    """Property: array_sum sollte nie bei gültigen Float-Arrays crashen

    Hypothesis generiert automatisch hunderte Testfälle
    mit unterschiedlichen Array-Größen und -Werten.
    """
    if len(arr) == 0:
        with pytest.raises(ValueError):
            array_sum(np.array(arr))
    else:
        result = array_sum(np.array(arr))
        assert isinstance(result, (int, float, np.number))

Wie groß ist “groß genug” für Unit-Tests?

Faustregel:

Array-Größe Wann zu verwenden Beispiel
0-2 Elemente Boundary-Testing Leer, einzeln, Paar (Edge-Cases)
3-10 Elemente Unit-Tests (am häufigsten) Genug um Logik zu testen ohne Rauschen
100-1000 Elemente Realistische Szenario-Tests Typische Real-World-Datengröße
10.000+ Elemente Performance-Tests (separate Suite) Algorithmus-Komplexität, Speichernutzung
1.000.000+ Elemente Stress-Tests (selten, nur CI) Produktions-Skala Daten-Validierung

Beispiel: Richtig dimensionierte Unit-Tests

# ✅ GUT: Repräsentative Größen für Unit-Tests
def test_find_intersection_typical_road():
    """Test mit typischer Road-Größe (~100 Punkte)"""
    x_road = np.linspace(0, 80, 100)  # Realistisch: 100 Punkte über 80 Meter
    y_road = generate_road_profile(num_points=100)

    x, y, dist = find_intersection(x_road, y_road, -10.0)
    assert x is not None  # Schneller Test, läuft in ~1ms

# ❌ SCHLECHT: Unnötig groß für Unit-Test
def test_find_intersection_huge_road():
    """Dies gehört in Performance-Tests, nicht Unit-Tests"""
    x_road = np.linspace(0, 10000, 1_000_000)  # Overkill!
    y_road = np.random.rand(1_000_000)

    x, y, dist = find_intersection(x_road, y_road, -10.0)
    assert x is not None  # Langsamer Test, läuft in ~500ms

Zusammenfassung: Array-Größen testen

Kernprinzip: Unit-Tests verifizieren Korrektheit, Performance-Tests verifizieren Geschwindigkeit. Halte sie getrennt!


2.4 Boundaries für komplexes Array-Beispiel: find_intersection()

Funktion: find_intersection(x_road, y_road, angle_degrees, camera_x, camera_y)

Array-Längen-Boundaries:

Boundary Testfall Warum wichtig
len(x_road) = 0 Leere Arrays Keine Road zum Schneiden
len(x_road) = 1 Einzelner Punkt Kann kein Segment bilden
len(x_road) = 2 Zwei Punkte Minimal gültige Road (ein Segment)
len(x_road) ≠ len(y_road) Nicht übereinstimmende Längen Ungültiger Input

Winkel-Boundaries:

Boundary Testwerte Warum wichtig
Exakt -90° -90.0 Vertikal abwärts (tan = ∞)
Nahe -90° -89.9, -89.999 Fast vertikal
Exakt 0° 0.0 Horizontaler Strahl
Nahe 0° -0.1, 0.1 Fast horizontal
Exakt 90° 90.0 Vertikal aufwärts (tan = ∞)
Nahe 90° 89.9, 89.999 Fast vertikal

Kamera-Positions-Boundaries:

Boundary Testfall Warum wichtig
camera_x = x_road[0] Kamera am Road-Start Rand der Road
camera_x = x_road[-1] Kamera am Road-Ende Rand der Road
camera_y = y_road[i] Kamera auf Road-Niveau Könnte Tangente sein
camera_y = min(y_road) Kamera am tiefsten Punkt Boundary-Fall
camera_y = max(y_road) Kamera am höchsten Punkt Boundary-Fall

Intersektions-Positions-Boundaries:

Boundary Testfall Warum wichtig
Intersektion bei x_road[0] Strahl trifft ersten Punkt exakt Endpunkt-Handling
Intersektion bei x_road[-1] Strahl trifft letzten Punkt exakt Endpunkt-Handling
Intersektion zwischen zwei Segmenten Strahl trifft an Segment-Grenze Interpolations-Edge-Case

Umfassende Boundary-Tests:

class TestFindIntersectionBoundaries:
    """Boundary-Value-Tests für find_intersection()"""

    # ARRAY-LÄNGEN-BOUNDARIES

    def test_boundary_empty_arrays(self):
        """Boundary: Leere Road-Arrays (len = 0)"""
        x, y, dist = find_intersection(np.array([]), np.array([]), -10.0)
        assert x is None

    def test_boundary_single_point(self):
        """Boundary: Einzelner Punkt (len = 1)"""
        x, y, dist = find_intersection(np.array([5.0]), np.array([2.0]), -10.0)
        assert x is None  # Kann kein Segment bilden

    def test_boundary_two_points_minimum_valid(self):
        """Boundary: Zwei Punkte (len = 2, minimal gültig)"""
        x_road = np.array([0.0, 10.0])
        y_road = np.array([0.0, 2.0])
        x, y, dist = find_intersection(x_road, y_road, -10.0, camera_x=0.0, camera_y=5.0)
        assert x is not None  # Sollte funktionieren

    # WINKEL-BOUNDARIES

    def test_boundary_angle_exactly_negative_90(self):
        """Boundary: Winkel exakt -90° (vertikal abwärts)"""
        x_road = np.array([0, 10, 20])
        y_road = np.array([0, 2, 4])
        x, y, dist = find_intersection(x_road, y_road, -90.0)
        # Implementierung gibt None für vertikal zurück - verifiziere dass das beabsichtigt ist
        assert x is None

    def test_boundary_angle_near_negative_90(self):
        """Boundary: Winkel nahe -90° (-89.9°)"""
        x_road = np.array([0, 10, 20])
        y_road = np.array([0, 2, 4])
        x, y, dist = find_intersection(x_road, y_road, -89.9, camera_x=0.0, camera_y=10.0)
        # Fast vertikal - sollte trotzdem Intersektion finden wenn eine existiert

    def test_boundary_angle_exactly_zero(self):
        """Boundary: Winkel exakt 0° (horizontal)"""
        x_road = np.array([0, 10, 20])
        y_road = np.array([2.0, 2.0, 2.0])  # Flache Road bei y=2
        x, y, dist = find_intersection(x_road, y_road, 0.0, camera_x=-5.0, camera_y=2.0)
        assert x is not None  # Horizontaler Strahl sollte flache Road treffen

    def test_boundary_angle_near_zero(self):
        """Boundary: Winkel nahe 0° (0.1° - fast horizontal)"""
        x_road = np.array([0, 10, 20, 30])
        y_road = np.array([0, 1, 2, 3])
        x, y, dist = find_intersection(x_road, y_road, 0.1, camera_x=0.0, camera_y=1.5)
        # Sehr flacher Winkel

    def test_boundary_angle_exactly_90(self):
        """Boundary: Winkel exakt 90° (vertikal aufwärts)"""
        x_road = np.array([0, 10, 20])
        y_road = np.array([0, 2, 4])
        x, y, dist = find_intersection(x_road, y_road, 90.0)
        assert x is None  # Implementierung gibt None für vertikal zurück

    # KAMERA-POSITIONS-BOUNDARIES

    def test_boundary_camera_at_road_start(self):
        """Boundary: Kamera-x-Position am Road-Start"""
        x_road = np.array([0, 10, 20])
        y_road = np.array([0, 2, 4])
        x, y, dist = find_intersection(x_road, y_road, -10.0, camera_x=0.0, camera_y=10.0)
        assert x is not None

    def test_boundary_camera_at_road_end(self):
        """Boundary: Kamera-x-Position am Road-Ende"""
        x_road = np.array([0, 10, 20])
        y_road = np.array([0, 2, 4])
        x, y, dist = find_intersection(x_road, y_road, -10.0, camera_x=20.0, camera_y=10.0)
        # Kamera am Ende, schaut nach unten - könnte schneiden oder nicht

    def test_boundary_camera_at_road_level(self):
        """Boundary: Kamera-y-Position auf Road-Niveau"""
        x_road = np.array([0, 10, 20])
        y_road = np.array([2.0, 2.0, 2.0])  # Flach bei y=2
        x, y, dist = find_intersection(x_road, y_road, 0.0, camera_x=5.0, camera_y=2.0)
        # Kamera AUF der Road, horizontaler Strahl - Tangenten-Fall

    def test_boundary_camera_at_minimum_y(self):
        """Boundary: Kamera am tiefsten Punkt der Road"""
        x_road = np.array([0, 10, 20, 30])
        y_road = np.array([5, 2, 3, 6])  # min bei x=10, y=2
        x, y, dist = find_intersection(x_road, y_road, -10.0, camera_x=15.0, camera_y=2.0)
        # Kamera auf gleicher Höhe wie tiefster Road-Punkt

    # INTERSEKTIONS-POSITIONS-BOUNDARIES

    def test_boundary_intersection_at_first_point(self):
        """Boundary: Strahl schneidet am ersten Road-Punkt"""
        x_road = np.array([0, 10, 20])
        y_road = np.array([5, 3, 1])
        # Positioniere Kamera so dass Strahl exakt (0, 5) trifft
        x, y, dist = find_intersection(x_road, y_road, -45.0, camera_x=-5.0, camera_y=10.0)
        if x is not None:
            assert abs(x - 0.0) < 0.1  # Sollte nahe am ersten Punkt sein

    def test_boundary_intersection_at_last_point(self):
        """Boundary: Strahl schneidet am letzten Road-Punkt"""
        x_road = np.array([0, 10, 20])
        y_road = np.array([0, 2, 4])
        # Positioniere so dass Strahl letzten Punkt trifft
        x, y, dist = find_intersection(x_road, y_road, -10.0, camera_x=15.0, camera_y=10.0)
        if x is not None and x > 18:
            assert abs(x - 20.0) < 2.0  # Sollte nahe am letzten Punkt sein

    def test_boundary_intersection_at_segment_boundary(self):
        """Boundary: Strahl schneidet exakt wo zwei Segmente sich treffen"""
        x_road = np.array([0, 10, 20, 30])
        y_road = np.array([0, 5, 5, 10])
        # Strahl zielt auf exakt (10, 5)
        x, y, dist = find_intersection(x_road, y_road, -30.0, camera_x=5.0, camera_y=10.0)
        if x is not None:
            # Könnte als Ende des ersten oder Start des zweiten Segments gemeldet werden
            assert 9.0 <= x <= 11.0

Wichtige Erkenntnisse für Boundary-Testing:

  1. Teste an der Boundary, knapp innerhalb und knapp außerhalb
    • Winkel = 90°, 89.9°, 90.1°
    • Länge = 0, 1, 2
  2. Gleitkomma-Präzision ist wichtig
    • Nutze pytest.approx() für Gleitkomma-Vergleiche
    • Teste Werte nahe Null (1e-10, 1e-15)
  3. Array-Boundaries sind sowohl strukturell als auch positional
    • Längen-Boundaries (leer, einzeln, zwei)
    • Positions-Boundaries (erstes Element, letztes Element, Grenzen dazwischen)
  4. Kombinations-Boundaries sind kritisch
    • Kamera auf Road-Niveau + horizontaler Strahl = Tangenten-Fall
    • Leeres Array + beliebiger Winkel = sollte elegant handhaben

Allgemeine Boundary-Testing-Strategie:

Datentyp Zu testende Boundary-Werte Python-Tools
Integers 0, 1, -1, max_value, min_value sys.maxsize, -sys.maxsize-1
Floats (Endlich) 0.0, nahe-Null (1e-10), 1.0, sehr groß (1e10), sys.float_info.max, -sys.float_info.max, sys.float_info.min (kleinster normalisierter), math.ulp(0.0) (kleinster subnormaler), sys.float_info.epsilon sys.float_info, math.ulp(x), math.nextafter(x, y)
Floats (Nicht-Endlich) math.inf, -math.inf, math.nan math.isnan(x), math.isinf(x), math.isfinite(x)
Arrays/Listen Leer ([]), Einzelnes Element, Zwei Elemente, Sehr groß len(), np.size
Winkel (Grad) , ±90°, ±180°, ±360°, Werte nahe diesen (89.9°, 90.1°) np.deg2rad(), np.rad2deg()
Strings Leerer String (""), Einzelnes Zeichen, Sehr langer String, Unicode-Edge-Cases len(), str.encode()

Schnellreferenz: Python Float Boundary Testing Cheat Sheet

import sys
import math

# KONSTANTEN (einmal definieren, überall verwenden)
FMAX = sys.float_info.max              # Größter endlicher (≈1.8e308)
FMIN_NORM = sys.float_info.min         # Kleinster normalisierter (≈2.2e-308)
FMIN_SUB = math.ulp(0.0)               # Kleinster subnormaler (≈5e-324)
EPSILON = sys.float_info.epsilon       # Maschinen-Epsilon (≈2.2e-16)
INF = math.inf                         # Positive Unendlichkeit
NINF = -math.inf                       # Negative Unendlichkeit
NAN = math.nan                         # Not a number

# TEST-CHECKLISTE FÜR FLOAT-FUNKTIONEN

# ✅ Endliche Boundaries (teste Overflow/Underflow)
test_function(FMAX)           # Größter endlicher
test_function(-FMAX)          # Negativster endlicher
test_function(FMIN_NORM)      # Kleinster normalisierter positiver
test_function(FMIN_SUB)       # Kleinster subnormaler positiver

# ✅ Nicht-endliche Werte (teste Spezialwert-Handling)
test_function(INF)            # Positive Unendlichkeit
test_function(NINF)           # Negative Unendlichkeit
test_function(NAN)            # Not a Number

# ✅ Präzisions-Boundaries
test_function(EPSILON)        # Maschinen-Epsilon
test_function(1.0 + EPSILON)  # Nächster nach 1.0

# ✅ Haarscharf-Übergänge (mit math.nextafter)
math.nextafter(FMAX, INF)     # → inf (Overflow-Übergang)
math.nextafter(1.0, 2.0)      # Nächster darstellbarer nach 1.0
math.nextafter(1.0, 0.0)      # Vorheriger darstellbarer vor 1.0

# ✅ Ergebnisse prüfen
math.isfinite(x)              # True wenn nicht inf/nan
math.isinf(x)                 # True wenn +inf oder -inf
math.isnan(x)                 # True wenn nan (nicht x == nan verwenden!)
math.ulp(x)                   # Abstand bei x (Präzision)

Entscheidungsbaum: Welche Boundaries sollte ich testen?

Macht Ihre Funktion Arithmetik mit Floats?
│
├─ JA, Division oder Kehrwert → Teste ALLE Boundaries (endlich + nicht-endlich)
│   ├─ sys.float_info.max (endlicher Overflow)
│   ├─ sys.float_info.min (Underflow-zu-Overflow)
│   ├─ math.inf (Division-durch-Null-Ergebnis)
│   ├─ math.nan (ungültiger Input)
│   └─ Nahe Null (1e-10, 1e-100)
│
├─ JA, aber einfache Operationen (+, -, *) → Teste endliche Boundaries
│   ├─ sys.float_info.max (Overflow-Erkennung)
│   ├─ sys.float_info.epsilon (Präzisionsverlust)
│   └─ Normale Bereichswerte
│
└─ NEIN, nur Vergleich/Sortierung → Teste grundlegende Boundaries
    ├─ 0.0, 1.0, -1.0
    ├─ math.inf, -math.inf (Sortierungs-Edge-Cases)
    └─ math.nan (Vergleich gibt immer False zurück!)

2.5 Boundaries für diskrete Funktionen: Schwellenwerte sind auch Boundaries!

Häufiges Missverständnis: “Diskrete Funktionen haben keine Boundary-Werte - sie haben nur Kategorien.”

Realität: Diskrete Funktionen HABEN Boundaries - sie sind sogar einfacher zu identifizieren als Boundaries kontinuierlicher Funktionen!

Was wir bisher gesehen haben:

Alle Boundary-Beispiele in dieser Vorlesung (reciprocal, reciprocal_sum, array_sum, find_intersection) waren kontinuierliche reellwertige Funktionen - sie geben Floats aus einem unendlichen Bereich zurück. Für diese mussten wir sorgfältig überlegen:

Die gute Nachricht: Funktionen mit diskreten/endlichen Ausgaben machen die Boundary-Identifikation viel einfacher! Die Boundaries sind explizite Schwellenwerte im Code.

Zentrale Erkenntnis: Für diskrete Funktionen sind Boundaries Schwellenwerte, bei denen die Ausgabe die Kategorie wechselt. Anstatt sich zu fragen “wie nah an Null?”, testet man beide Seiten jedes Schwellenwerts.


2.5.1 Beispiel: Einfache Schwellenwert-Boundaries - calculate_grade(score)

Beginnen wir mit dem einfachsten Fall: eine Funktion, die kontinuierliche Eingaben auf diskrete Ausgaben abbildet.

Funktionsdefinition:

def calculate_grade(score: int) -> str:
    """
    Berechne Buchstabennote basierend auf Punktzahl.

    Args:
        score: Ganzzahl zwischen 0 und 100

    Returns:
        Buchstabennote (A, B, C, D, F)

    Raises:
        ValueError: Wenn Punktzahl nicht im gültigen Bereich liegt
    """
    if score < 0 or score > 100:
        raise ValueError("Score must be between 0 and 100")

    if score >= 90:
        return "A"
    elif score >= 80:
        return "B"
    elif score >= 70:
        return "C"
    elif score >= 60:
        return "D"
    else:
        return "F"

Frage: Wo sind die Boundaries für diese Funktion?

Analyse:

Beim Betrachten des Codes sehen wir sofort explizite Schwellenwerte:

Äquivalenzklassen und ihre Boundaries:

Notenklasse Bereich Zu testende Boundaries Warum diese Boundaries?
Note A 90 ≤ score ≤ 100 90, 100 Unterer Schwellenwert (A/B-Grenze), oberes Limit
Note B 80 ≤ score < 90 80, 89 Unterer Schwellenwert (B/C-Grenze), oberer Rand (B/A-Grenze)
Note C 70 ≤ score < 80 70, 79 Unterer Schwellenwert (C/D-Grenze), oberer Rand (C/B-Grenze)
Note D 60 ≤ score < 70 60, 69 Unterer Schwellenwert (D/F-Grenze), oberer Rand (D/C-Grenze)
Note F 0 ≤ score < 60 0, 59 Unteres Limit, oberer Rand (F/D-Grenze)
Ungültig score < 0 oder score > 100 -1, 101 Knapp außerhalb des gültigen Bereichs

Zentrale Erkenntnis - Zwei Arten von Boundary-Werten:

  1. Schwellenwert-Boundaries - Wo die Ausgabekategorie wechselt:
    • score = 89 sollte “B” zurückgeben
    • score = 90 sollte “A” zurückgeben
    • Diese sind kritisch! Off-by-One-Fehler sind häufig (> statt >=)
  2. Bereichs-Boundaries - Gültige Eingabebereichsgrenzen:
    • score = 0 (minimal gültig)
    • score = 100 (maximal gültig)
    • score = -1 (knapp unterhalb gültiger Bereich)
    • score = 101 (knapp oberhalb gültiger Bereich)

Boundary-Value-Testcode:

import pytest

def test_grade_boundary_A_B_threshold():
    """Boundary: score = 89 (B) vs 90 (A)"""
    assert calculate_grade(89) == "B"  # Knapp unter A-Schwelle
    assert calculate_grade(90) == "A"  # An A-Schwelle

def test_grade_boundary_B_C_threshold():
    """Boundary: score = 79 (C) vs 80 (B)"""
    assert calculate_grade(79) == "C"  # Knapp unter B-Schwelle
    assert calculate_grade(80) == "B"  # An B-Schwelle

def test_grade_boundary_C_D_threshold():
    """Boundary: score = 69 (D) vs 70 (C)"""
    assert calculate_grade(69) == "D"  # Knapp unter C-Schwelle
    assert calculate_grade(70) == "C"  # An C-Schwelle

def test_grade_boundary_D_F_threshold():
    """Boundary: score = 59 (F) vs 60 (D)"""
    assert calculate_grade(59) == "F"  # Knapp unter D-Schwelle
    assert calculate_grade(60) == "D"  # An D-Schwelle

def test_grade_boundary_valid_range_lower():
    """Boundary: score = 0 (gültig) vs -1 (ungültig)"""
    assert calculate_grade(0) == "F"   # Minimale gültige Punktzahl
    with pytest.raises(ValueError):
        calculate_grade(-1)             # Knapp unter gültigem Bereich

def test_grade_boundary_valid_range_upper():
    """Boundary: score = 100 (gültig) vs 101 (ungültig)"""
    assert calculate_grade(100) == "A"  # Maximale gültige Punktzahl
    with pytest.raises(ValueError):
        calculate_grade(101)             # Knapp über gültigem Bereich

Beobachtungen zum Testdesign:

Kontrast zu kontinuierlichen Funktionen:

Aspekt Kontinuierlich (reciprocal) Diskret (calculate_grade)
Boundary-Identifikation Muss entschieden werden: "Wie nah an Null?" (1e-10? 1e-100?) Explizit im Code: 90, 80, 70, 60
Anzahl Boundaries Hängt von Partitionierungswahl ab Festgelegt durch Funktionslogik
Boundary-Präzision Fließkomma-Präzision wichtig (epsilon) Ganzzahlwerte - keine Präzisionsprobleme
Teststrategie Teste nahe-Null, Infinity, NaN, epsilon Teste Schwellenwert ± 1

Warum diskrete Boundaries einfacher sind:

  1. Explizite Schwellenwerte - Der Code sagt buchstäblich if score >= 90, also weiß man, dass man 89 vs 90 testen muss
  2. Keine Präzisionssorgen - Ganzzahl-Schwellenwerte bedeuten keine Fließkomma-Edge-Cases
  3. Klares Pass/Fail - Entweder ist die Note korrekt oder nicht - keine “nah genug”-Entscheidungen
  4. Off-by-One-Erkennung - Boundary-Tests fangen sofort > vs >= Fehler

2.5.2 Beispiel: Multi-dimensionale Boundaries - calculate_shipping_cost(weight, distance, express)

Jetzt sehen wir uns einen komplexeren Fall an: mehrere Parameter, jeder mit eigenen Boundaries.

Funktionsdefinition:

def calculate_shipping_cost(weight: float, distance: float, express: bool = False) -> float:
    """
    Berechne Versandkosten basierend auf Gewicht und Entfernung.

    Args:
        weight: Gewicht in kg (0.1 bis 50)
        distance: Entfernung in km (1 bis 5000)
        express: Ob Expressversand gewünscht ist

    Returns:
        float: Versandkosten in EUR

    Raises:
        ValueError: Wenn Gewicht oder Entfernung außerhalb des Bereichs liegt
    """
    if weight < 0.1 or weight > 50:
        raise ValueError("Weight must be between 0.1 and 50 kg")

    if distance < 1 or distance > 5000:
        raise ValueError("Distance must be between 1 and 5000 km")

    # Basiskosten-Berechnung
    if weight <= 5:
        base_cost = 5.0
    elif weight <= 20:
        base_cost = 10.0
    else:
        base_cost = 20.0

    # Entfernungsmultiplikator
    if distance <= 100:
        distance_multiplier = 1.0
    elif distance <= 500:
        distance_multiplier = 1.5
    else:
        distance_multiplier = 2.0

    cost = base_cost * distance_multiplier

    # Expressversand fügt 50% hinzu
    if express:
        cost *= 1.5

    return round(cost, 2)

Frage: Wo sind die Boundaries für diese Funktion?

Analyse:

Diese Funktion hat drei Dimensionen, jede mit eigenen Boundaries:

Dimension 1: Gewichts-Boundaries

Boundary Schwellenwert Testfälle Erwartete Verhaltensänderung
Mindestgewicht 0.1 kg 0.09 kg (Fehler) vs 0.1 kg (gültig) Fehler → Leicht-Kategorie
Leicht/Mittel 5 kg 5.0 kg (leicht) vs 5.1 kg (mittel) €5 Basis → €10 Basis
Mittel/Schwer 20 kg 20.0 kg (mittel) vs 20.1 kg (schwer) €10 Basis → €20 Basis
Maximalgewicht 50 kg 50 kg (gültig) vs 50.1 kg (Fehler) Schwer-Kategorie → Fehler

Dimension 2: Entfernungs-Boundaries

Boundary Schwellenwert Testfälle Erwartete Verhaltensänderung
Mindestentfernung 1 km 0.9 km (Fehler) vs 1 km (gültig) Fehler → Lokal-Kategorie
Lokal/Regional 100 km 100 km (lokal) vs 101 km (regional) 1.0× Multiplikator → 1.5× Multiplikator
Regional/Langstrecke 500 km 500 km (regional) vs 501 km (lang) 1.5× Multiplikator → 2.0× Multiplikator
Maximalentfernung 5000 km 5000 km (gültig) vs 5001 km (Fehler) Langstrecke → Fehler

Dimension 3: Express-Flag

Wert Effekt
False 1.0× (Standardversand)
True 1.5× (Express-Multiplikator)

Hinweis: Boolean-Parameter haben keine “Boundaries” im traditionellen Sinne - sie haben nur zwei Werte. Aber wir müssen trotzdem beide testen!

Herausforderung: Kombinatorische Explosion

Wenn wir jede Kombination von Boundaries testen würden:

Praktische Strategie: Jede Dimension unabhängig testen

Stattdessen testen wir die Boundaries jeder Dimension, während andere Dimensionen konstant gehalten werden:

import pytest

# ===== Gewichts-Boundaries (Entfernung und Express fixiert) =====

def test_shipping_weight_boundary_minimum():
    """Boundary: weight = 0.1 (gültig) vs 0.09 (ungültig)"""
    assert calculate_shipping_cost(0.1, 100, False) == 5.0  # Gültig
    with pytest.raises(ValueError, match="Weight must be between"):
        calculate_shipping_cost(0.09, 100, False)  # Ungültig

def test_shipping_weight_boundary_light_medium():
    """Boundary: weight = 5.0 (leicht) vs 5.1 (mittel)"""
    cost_light = calculate_shipping_cost(5.0, 100, False)
    cost_medium = calculate_shipping_cost(5.1, 100, False)
    assert cost_light == 5.0   # Leicht: base=5.0, dist_mult=1.0 → 5.0
    assert cost_medium == 10.0  # Mittel: base=10.0, dist_mult=1.0 → 10.0

def test_shipping_weight_boundary_medium_heavy():
    """Boundary: weight = 20.0 (mittel) vs 20.1 (schwer)"""
    cost_medium = calculate_shipping_cost(20.0, 100, False)
    cost_heavy = calculate_shipping_cost(20.1, 100, False)
    assert cost_medium == 10.0  # Mittel: base=10.0, dist_mult=1.0 → 10.0
    assert cost_heavy == 20.0   # Schwer: base=20.0, dist_mult=1.0 → 20.0

def test_shipping_weight_boundary_maximum():
    """Boundary: weight = 50 (gültig) vs 50.1 (ungültig)"""
    assert calculate_shipping_cost(50, 100, False) == 20.0  # Gültig
    with pytest.raises(ValueError, match="Weight must be between"):
        calculate_shipping_cost(50.1, 100, False)  # Ungültig

# ===== Entfernungs-Boundaries (Gewicht und Express fixiert) =====

def test_shipping_distance_boundary_minimum():
    """Boundary: distance = 1 (gültig) vs 0.9 (ungültig)"""
    assert calculate_shipping_cost(5.0, 1, False) == 5.0  # Gültig
    with pytest.raises(ValueError, match="Distance must be between"):
        calculate_shipping_cost(5.0, 0.9, False)  # Ungültig

def test_shipping_distance_boundary_local_regional():
    """Boundary: distance = 100 (lokal) vs 101 (regional)"""
    cost_local = calculate_shipping_cost(5.0, 100, False)
    cost_regional = calculate_shipping_cost(5.0, 101, False)
    assert cost_local == 5.0     # Lokal: base=5.0, dist_mult=1.0 → 5.0
    assert cost_regional == 7.5  # Regional: base=5.0, dist_mult=1.5 → 7.5

def test_shipping_distance_boundary_regional_long():
    """Boundary: distance = 500 (regional) vs 501 (Langstrecke)"""
    cost_regional = calculate_shipping_cost(5.0, 500, False)
    cost_long = calculate_shipping_cost(5.0, 501, False)
    assert cost_regional == 7.5  # Regional: base=5.0, dist_mult=1.5 → 7.5
    assert cost_long == 10.0     # Lang: base=5.0, dist_mult=2.0 → 10.0

def test_shipping_distance_boundary_maximum():
    """Boundary: distance = 5000 (gültig) vs 5001 (ungültig)"""
    assert calculate_shipping_cost(5.0, 5000, False) == 10.0  # Gültig
    with pytest.raises(ValueError, match="Distance must be between"):
        calculate_shipping_cost(5.0, 5001, False)  # Ungültig

# ===== Express-Flag (Gewicht und Entfernung fixiert) =====

def test_shipping_express_flag():
    """Dimension: express = False vs True"""
    cost_standard = calculate_shipping_cost(5.0, 100, False)
    cost_express = calculate_shipping_cost(5.0, 100, True)
    assert cost_standard == 5.0   # Standard: base=5.0, dist=1.0, express=1.0 → 5.0
    assert cost_express == 7.5    # Express: base=5.0, dist=1.0, express=1.5 → 7.5

Beobachtungen zum Testdesign:

  1. Unabhängiges Dimensionstesten - Jede Dimension wird getestet, während andere konstant gehalten werden
  2. Total: ~12 Tests statt 32 (alle Kombinationen) oder 100+ (erschöpfend)
  3. Wir fangen trotzdem Boundary-Bugs - Wenn Gewichts-Schwellenwert falsch ist (weight < 5 statt weight <= 5), schlägt unser Test fehl
  4. Klare Testnamen - Jeder Test gibt explizit an, welche Boundary getestet wird

Wann Kombinationen testen:

Kombinationstests sollten hinzugefügt werden, wenn:

Für diese Funktion sind Dimensionen unabhängig (nur Multiplikation), daher ist unabhängiges Testen ausreichend.

Zentrale Erkenntnis für multi-dimensionale Boundaries:

Teste jede Dimension unabhängig mit repräsentativen Werten in anderen Dimensionen. Dies ergibt \(O(d \times b)\) Tests (d Dimensionen, b Boundaries jeweils) statt \(O(b^d)\) erschöpfende Tests!

Für Versandkosten: 12 Tests (3 Dimensionen × ~4 Boundaries) statt 32 Tests (alle Kombinationen).


2.5.3 Zusammenfassung: Boundary-Testing-Strategie für diskrete Funktionen

Kernprinzipien:

  1. Boundaries sind explizit - Suche nach Schwellenwerten in bedingter Logik (if, elif)
  2. Teste beide Seiten jedes Schwellenwerts - wert-1 (alte Kategorie) vs wert (neue Kategorie)
  3. Teste Bereichsgrenzen - Minimal gültig, maximal gültig, knapp außerhalb des Bereichs
  4. Für multi-dimensionale Funktionen: Teste jede Dimension unabhängig (außer Dimensionen interagieren)

Häufige Schwellenwert-Muster, nach denen man suchen sollte:

Muster im Code Zu testende Boundaries Beispiel
if x >= schwelle: schwelle-1, schwelle score >= 90 → teste 89, 90
if x > schwelle: schwelle, schwelle+1 weight > 5 → teste 5.0, 5.1
if x < schwelle: schwelle-1, schwelle distance < 100 → teste 99, 100
if x <= schwelle: schwelle, schwelle+1 weight <= 5 → teste 5.0, 5.1
if min <= x <= max: min-1, min, max, max+1 0 <= score <= 100 → teste -1, 0, 100, 101

Vergleich: Kontinuierliche vs diskrete Boundaries

Aspekt Kontinuierliche Funktionen Diskrete Funktionen
Boundary-Identifikation Muss gewählt werden: "Wie nah an problematischem Wert?" Explizite Schwellenwerte im Code
Testwerte Nahe-Null, Infinity, NaN, epsilon Schwellenwert ± kleinste Einheit (1 für Ganzzahlen, 0.1 für Floats)
Präzisionsbedenken Fließkomma-Präzision kritisch Normalerweise keine (Ganzzahl-Schwellenwerte)
Anzahl Boundaries Hängt von Partitionierungswahl ab Festgelegt durch Funktionslogik
Häufig gefangene Bugs Division durch Null, Overflow, Präzisionsverlust Off-by-One-Fehler (> vs >=)

Die gute Nachricht: Diskrete Funktionen machen Boundary-Testing einfacher, nicht schwerer! Die Schwellenwerte stehen direkt im Code - man muss nur beide Seiten jedes einzelnen testen.

Abschließende Erkenntnis:

Jede Bedingung erzeugt eine Boundary. Wenn man if x >= 90 sieht, weiß man, dass man x=89 und x=90 testen muss. Wenn man if weight <= 5 sieht, weiß man, dass man weight=5.0 und weight=5.1 testen muss.

Boundary-Testing für diskrete Funktionen ist systematisch und mechanisch - deshalb ist es so effektiv!


3. LLM-Assisted Testing - Den Test-Cone durchbrechen

Warum schreiben Entwickler keine Tests?

Forschung und Umfragen zeigen:

  1. “Tests schreiben ist langweilig” (42% der Entwickler)
    • Boilerplate: imports, setup, teardown
    • Repetitiv: Ähnliche Struktur für jede Funktion
  2. “Ich weiß nicht was ich testen soll” (38% der Entwickler)
    • Was sind die Äquivalenzklassen?
    • Was sind die Boundary-Cases?
    • Wie viele Tests brauche ich?
  3. “Erstes Setup dauert ewig” (35% der Entwickler)
    • pytest-Konfiguration
    • Test-Dateistruktur
    • Erster Test ist immer am schwersten

Ergebnis: Entwickler prokrastinieren → Schreiben nur E2E-Tests → Invertierte Pyramide (Test-Cone)

Die Lösung: Nutzen Sie LLMs für die langweiligen Teile, konzentrieren Sie sich auf Korrektheit.

Der Workflow:

1. Mensch: Identifiziert was getestet werden muss (Äquivalenzklassen)
2. LLM: Generiert Test-Boilerplate (imports, Struktur, AAA-Pattern)
3. Mensch: Reviewed und verfeinert (fixt Logik, fügt Edge-Cases hinzu)
4. LLM: Generiert mehr Tests basierend auf Feedback
5. Mensch: Verifiziert dass Assertions korrekt sind
6. Tests ausführen: Fange Bugs in tatsächlichem Code (nicht Tests!)

Wichtige Erkenntnis: LLMs sind exzellent in Boilerplate, Menschen sind exzellent in Domänenlogik.

Szenario: Sie müssen find_intersection() aus src/road_profile_viewer/geometry.py testen.

Schritt 1: Prompte das LLM (in tests/test_geometry.py)

# tests/test_geometry.py
import numpy as np
from numpy.typing import NDArray
import pytest
from road_profile_viewer.geometry import find_intersection

# Prompt: "Schreibe pytest Unit-Tests für find_intersection() die abdecken:
# - Äquivalenzklassen: Abwärtswinkel, horizontal, Aufwärtswinkel, vertikal
# - Boundary-Cases: leere Arrays, einzelner Punkt, angle=90
# - Nutze AAA-Pattern (Arrange-Act-Assert)
# - Inkludiere korrekte Type-Hints passend zum tatsächlichen Repository-Code"

Schritt 2: LLM Generiert initiale Tests

Copilot/Claude könnte generieren:

def test_find_intersection_downward_angle() -> None:
    """Teste find_intersection mit normalem Abwärtswinkel."""
    x_road: NDArray[np.float64] = np.array([0, 10, 20, 30], dtype=np.float64)
    y_road: NDArray[np.float64] = np.array([0, 2, 4, 6], dtype=np.float64)
    x, y, dist = find_intersection(x_road, y_road, -10.0, 0.0, 10.0)
    assert x is not None
    assert y is not None
    assert dist is not None and dist > 0

def test_find_intersection_horizontal() -> None:
    """Teste find_intersection mit horizontalem Winkel."""
    x_road: NDArray[np.float64] = np.array([0, 10, 20], dtype=np.float64)
    y_road: NDArray[np.float64] = np.array([0, 0, 0], dtype=np.float64)  # Flache Road
    x, y, dist = find_intersection(x_road, y_road, 0.0, 0.0, 1.0)
    assert x is not None

def test_find_intersection_vertical() -> None:
    """Teste find_intersection mit vertikalem Winkel (90 Grad)."""
    x_road: NDArray[np.float64] = np.array([0, 10, 20], dtype=np.float64)
    y_road: NDArray[np.float64] = np.array([0, 2, 4], dtype=np.float64)
    x, y, dist = find_intersection(x_road, y_road, 90.0)
    assert x is None  # Aktuelle Implementierung gibt None für vertikal zurück

def test_find_intersection_empty_road() -> None:
    """Teste find_intersection mit leeren Road-Arrays."""
    x_road: NDArray[np.float64] = np.array([], dtype=np.float64)
    y_road: NDArray[np.float64] = np.array([], dtype=np.float64)
    x, y, dist = find_intersection(x_road, y_road, -10.0)
    assert x is None

Wert: 80% des Boilerplates sofort geschrieben!

Schritt 3: Menschliches Review - Finde die Fehler

Jetzt SIE reviewen mit Domänenwissen:

❌ Problem 1: Fehlender Edge-Case

# LLM hat nicht getestet: Was ist wenn Kamera UNTER der Road ist?
# Füge diesen Test hinzu:
def test_find_intersection_camera_below_road() -> None:
    """Test wenn Kamera unter Road-Niveau ist."""
    x_road: NDArray[np.float64] = np.array([0, 10, 20], dtype=np.float64)
    y_road: NDArray[np.float64] = np.array([5, 5, 5], dtype=np.float64)  # Flache Road bei y=5
    x, y, dist = find_intersection(x_road, y_road, -10.0, 0.0, 0.0)  # Kamera bei y=0
    # Sollte trotzdem Intersektion finden (Strahl geht runter, kreuzt Road darüber)
    assert x is not None

❌ Problem 2: Schwache Assertions

# LLM schrieb:
assert x is not None  # Zu schwach!

# Bessere Assertion:
assert 0 <= x <= 30, f"Erwartete x in [0, 30], bekam {x}"
assert y >= 0, f"Erwartete y positiv, bekam {y}"

❌ Problem 3: Ungültige Testdaten

# LLM schrieb:
x_road: NDArray[np.float64] = np.array([0, 10, 20], dtype=np.float64)
y_road: NDArray[np.float64] = np.array([0, 0, 0], dtype=np.float64)  # Flache Road bei y=0
x, y, dist = find_intersection(x_road, y_road, 0.0, 0.0, 1.0)  # Kamera bei y=1

# Problem: Horizontaler Strahl von y=1 wird Road bei y=0 nicht schneiden!
# Fix: Entweder Kamera anheben oder Road neigen
x_road = np.array([0, 10, 20], dtype=np.float64)
y_road = np.array([0, 1, 2], dtype=np.float64)  # Geneigte Road

Schritt 4: Iterative Verfeinerung

# Sie zum LLM: "Fügen Sie Tests für Aufwärtswinkel hinzu wo Strahl nicht schneiden könnte"
# LLM generiert:
def test_find_intersection_upward_angle_no_intersection() -> None:
    """Teste dass Aufwärtswinkel mit Road darunter None zurückgibt."""
    x_road: NDArray[np.float64] = np.array([0, 10, 20], dtype=np.float64)
    y_road: NDArray[np.float64] = np.array([0, 1, 2], dtype=np.float64)
    x, y, dist = find_intersection(x_road, y_road, 45.0, 0.0, 0.0)  # Strahl geht hoch
    # Je nach Implementierung, könnte nicht schneiden
    # Wenn None korrektes Verhalten ist:
    assert x is None

Schritt 5: Führe Tests aus und finde echte Bugs

$ uv run pytest tests/test_geometry.py -v

Überraschung! Ein Test schlägt fehl:

FAILED test_find_intersection_empty_road - IndexError: index 0 is out of bounds

Das ist GUT! Der Test fand einen Bug in Ihrem tatsächlichen Code:

def find_intersection(x_road, y_road, ...):
    # Bug: Prüft nicht ob Arrays leer sind bevor zugegriffen wird!
    for i in range(len(x_road) - 1):  # Crasht wenn len(x_road) = 0
        x1, y1 = x_road[i], y_road[i]
        ...

Fixe den Bug:

def find_intersection(x_road, y_road, ...):
    # Füge Validierung hinzu
    if len(x_road) == 0 or len(y_road) == 0:
        return None, None, None

    # ... Rest der Funktion

Führe Tests erneut aus:

$ uv run pytest tests/test_geometry.py -v
============================= 8 passed in 0.12s ===============================

Alle Tests bestehen! Bug gefixt bevor er Nutzer erreicht.

Was LLMs GUT können:

Was LLMs SCHLECHT können:

Beispiel für LLM-Versagen #1: Schwache Assertions

# LLM könnte generieren:
def test_calculate_distance():
    dist = calculate_distance(0, 0, 3, 4)
    assert dist > 0  # Zu schwach! Prüft nur ob positiv

# Mensch kennt Pythagoras:
def test_calculate_distance():
    dist = calculate_distance(0, 0, 3, 4)
    assert abs(dist - 5.0) < 0.01, f"Erwartete 5.0, bekam {dist}"  # 3-4-5 Dreieck!

Beispiel für LLM-Versagen #2: Logik in Tests

LLMs generieren manchmal Tests mit Schleifen, Conditionals oder Berechnungen. Das macht Tests schwer verständlich und debugbar.

# ❌ LLM könnte das generieren:
def test_find_intersection_multiple_angles():
    """Test intersection für verschiedene Winkel"""
    angles = [-10, -20, -30, -45]
    for angle in angles:  # ❌ Schleife im Test!
        x, y, dist = find_intersection(x_road, y_road, angle)
        if x is not None:  # ❌ Conditional im Test!
            assert dist > 0
        else:
            assert dist is None  # ❌ Komplexe Logik!

Probleme mit diesem Test:

Menschlicher Fix: Separate Tests, keine Logik

# ✅ Mensch refactoriert zu einfachen, klaren Tests:
def test_find_intersection_returns_positive_distance_for_minus_10_degrees():
    """Test -10° Winkel gibt positive Distanz zurück"""
    x_road = np.array([0, 10, 20], dtype=np.float64)
    y_road = np.array([0, 2, 4], dtype=np.float64)
    x, y, dist = find_intersection(x_road, y_road, -10.0, 0.0, 10.0)
    assert dist > 0  # Keine Conditionals, keine Schleifen!

def test_find_intersection_returns_positive_distance_for_minus_20_degrees():
    """Test -20° Winkel gibt positive Distanz zurück"""
    x_road = np.array([0, 10, 20], dtype=np.float64)
    y_road = np.array([0, 2, 4], dtype=np.float64)
    x, y, dist = find_intersection(x_road, y_road, -20.0, 0.0, 10.0)
    assert dist > 0

# Oder nutze pytest parametrize (sauberer für mehrere ähnliche Tests):
@pytest.mark.parametrize("angle", [-10, -20, -30, -45])
def test_find_intersection_returns_positive_distance_for_downward_angles(angle):
    """Teste dass alle Abwärtswinkel positive Distanz zurückgeben"""
    x_road = np.array([0, 10, 20], dtype=np.float64)
    y_road = np.array([0, 2, 4], dtype=np.float64)
    x, y, dist = find_intersection(x_road, y_road, angle, 0.0, 10.0)
    assert dist > 0  # Einfache Assertion, pytest führt diesen Test 4-mal mit verschiedenen Winkeln aus

Kernprinzip: Tests sollten Straight-Line-Code sein (inspiriert von Googles Testing-Praktiken)

Warum?

Der Mensch-LLM-Loop:

1. Mensch: "Teste find_intersection für Edge-Cases"
2. LLM: Generiert 5 Tests
3. Mensch: "Dieser ist falsch - Kamera unter Road sollte trotzdem funktionieren"
4. LLM: Fixt diesen Test
5. Mensch: Führt Tests aus, findet Bug in tatsächlichem Code (nicht Test)
6. Mensch: Fixt Code
7. Tests bestehen → Vertrauen!

3.1 Warnung: LLMs lieben Mocking (Aber Sie sollten es nicht übertreiben)

Wenn Sie LLMs nutzen um Tests zu generieren, schlagen sie oft Mocking vor - echte Objekte durch Fakes ersetzen um Funktionsaufrufe zu verifizieren. Das kann zu brüchigen Tests führen die beim Refactoring brechen.

Google Testing Kernprinzip: Teste State, nicht Interactions

Es gibt zwei Wege zu verifizieren dass Code funktioniert:

  1. State Testing: Beobachte das System nach dem Aufruf um Outcomes zu verifizieren
  2. Interaction Testing: Verifiziere erwartete Sequenzen von Funktionsaufrufen auf Collaborators (mit Mocks)

State Testing ist weniger brüchig weil es sich auf “was” für Ergebnisse auftraten fokussiert statt “wie” Ergebnisse erzielt wurden.


3.1.1 Wie Mocking aussieht (und wann es problematisch ist)

# ❌ LLM-generierter Test mit exzessivem Mocking
from unittest.mock import patch

def test_find_intersection_calls_tan():
    """Teste dass find_intersection np.tan aufruft"""
    x_road = np.array([0, 10, 20], dtype=np.float64)
    y_road = np.array([0, 2, 4], dtype=np.float64)

    with patch('numpy.tan') as mock_tan:
        mock_tan.return_value = 0.176  # Gemockte Steigung
        x, y, dist = find_intersection(x_road, y_road, -10.0)
        mock_tan.assert_called_once()  # ❌ Testet WIE, nicht WAS

Problem: Dieser Test bricht wenn Sie:

Das ist ein brüchiger Test - er schlägt fehl wenn Implementierung sich ändert, obwohl Verhalten gleich bleibt.


3.1.2 Besserer Ansatz: Teste das Ergebnis, nicht die Methodenaufrufe

# ✅ Teste das ERGEBNIS (State), nicht die METHODENAUFRUFE (Interactions)
def test_find_intersection_returns_correct_intersection():
    """Teste dass Intersektions-Position geometrisch korrekt ist"""
    x_road = np.array([0, 10, 20], dtype=np.float64)
    y_road = np.array([0, 2, 4], dtype=np.float64)

    x, y, dist = find_intersection(x_road, y_road, -10.0, 0.0, 10.0)

    # Assert FINAL STATE (Outcomes)
    assert x is not None, "Sollte Intersektion für Abwärtswinkel finden"
    assert 0 <= x <= 20, f"Intersektion x sollte innerhalb Road-Grenzen sein, bekam {x}"
    assert y >= 0, f"Intersektion y sollte über Boden sein, bekam {y}"
    assert dist > 0, f"Distanz sollte positiv sein, bekam {dist}"

    # Keine Annahmen über welche numpy-Funktionen aufgerufen wurden!

Vorteile:


3.1.3 Wann IST Mocking angemessen?

Mocking ist wertvoll in spezifischen Szenarien:

✅ MOCKE:

❌ MOCKE NICHT:


3.1.4 Beispiel: Wann Mocking angemessen ist

# ✅ Gute Nutzung von Mocking: Externe API
from unittest.mock import patch, Mock

def test_fetch_weather_data_returns_temperature():
    """Teste Wetter-Abruf ohne echte API zu treffen"""
    # Mocke den externen HTTP-Request (langsam, braucht Netzwerk)
    with patch('requests.get') as mock_get:
        mock_response = Mock()
        mock_response.json.return_value = {'temp': 22.5}
        mock_get.return_value = mock_response

        # Jetzt testen Sie Ihre Funktion
        temp = fetch_weather_data('Berlin')

        # Assert ERGEBNIS (nicht Implementierung)
        assert temp == 22.5

Warum dieses Mocking gut ist:


3.1.5 Faustregel: Bevorzuge echte Objekte über Mocks

Für find_intersection() und ähnliche Funktionen:

Googles Richtlinie: “Nutze echte Objekte wenn sie schnell und deterministisch sind. Mocke nur wenn nötig.”


3.1.6 Zusammenfassung: State Testing vs. Interaction Testing

Aspekt State Testing (Bevorzugt) Interaction Testing (Sparsam nutzen)
Was es testet Finale Ergebnisse/Outcomes Sequenz von Funktionsaufrufen
Assertion-Stil assert x == expected_value mock.assert_called_once()
Brüchigkeit Niedrig - überlebt Refactoring Hoch - bricht wenn Implementierung sich ändert
Wann zu nutzen Immer, wenn möglich Nur externe Dependencies
Beispiel assert dist > 0 mock_api.get.assert_called()

Wichtige Erkenntnis: Wenn LLMs Mocking vorschlagen, fragen Sie sich: “Ist diese Dependency langsam oder extern?” Wenn nicht, nutzen Sie das echte Objekt und testen Sie den State!


Best Practice: Nutze LLM zum STARTEN, Mensch zum VERIFIZIEREN und VERFEINERN. Pass auf bei Über-Mocking!


4. Teil 4: Hands-On Übung - Teste geometry.py

4.1 Übung: Schreibe umfassende Tests für geometry.py

Ziel: Teste alle Funktionen in src/road_profile_viewer/geometry.py mittels Äquivalenzklassen und Boundary-Analyse.

Setup:

# Erstelle Test-Struktur falls noch nicht vorhanden
$ mkdir -p tests
$ touch tests/__init__.py
$ touch tests/test_geometry.py

Aufgabe 1: Teste find_intersection() - Äquivalenzklassen

Nutze ein LLM um initiale Tests zu generieren, dann verfeinere sie:

# tests/test_geometry.py
import numpy as np
from numpy.typing import NDArray
import pytest
from road_profile_viewer.geometry import find_intersection, calculate_ray_line


class TestFindIntersection:
    """Test-Suite für find_intersection() Funktion."""

    def test_normal_downward_angle(self) -> None:
        """Äquivalenzklasse: Normaler Abwärtswinkel (-90° < Winkel < 0°)"""
        x_road: NDArray[np.float64] = np.array([0, 10, 20, 30], dtype=np.float64)
        y_road: NDArray[np.float64] = np.array([0, 2, 4, 6], dtype=np.float64)
        x, y, dist = find_intersection(x_road, y_road, -45.0, 0.0, 10.0)
        assert x is not None and y is not None and dist is not None

    def test_horizontal_angle(self) -> None:
        """Äquivalenzklasse: Horizontaler Strahl (Winkel = 0°)"""
        # Ihr Test hier (lassen Sie LLM generieren, dann reviewen)
        pass

    def test_vertical_angle_boundary(self) -> None:
        """Boundary-Fall: Vertikaler Winkel (90°)"""
        pass

    def test_empty_road_arrays(self) -> None:
        """Boundary-Fall: Leere Arrays"""
        pass

    def test_single_point_road(self) -> None:
        """Boundary-Fall: Straße mit nur einem Punkt"""
        pass

Aufgabe 2: Teste calculate_ray_line() - Boundary-Fälle

class TestCalculateRayLine:
    """Test-Suite für calculate_ray_line() Funktion."""

    def test_normal_angle(self) -> None:
        """Test mit normalem Winkel"""
        x_ray: NDArray[np.float64]
        y_ray: NDArray[np.float64]
        x_ray, y_ray = calculate_ray_line(-10.0, camera_x=0.0, camera_y=2.0)
        assert len(x_ray) == 2
        assert len(y_ray) == 2
        assert x_ray[0] == 0.0  # Startet bei Kamera
        assert y_ray[0] == 2.0

    def test_vertical_angle(self) -> None:
        """Boundary: Vertikaler Winkel (90°)"""
        x_ray, y_ray = calculate_ray_line(90.0)
        # Sollte vertikale Linie handhaben
        assert x_ray[0] == x_ray[1]  # Vertikal bedeutet gleiches x

    def test_zero_angle(self) -> None:
        """Boundary: Horizontaler Winkel (0°)"""
        x_ray, y_ray = calculate_ray_line(0.0, camera_y=2.0)
        # Horizontale Linie bei y=2.0
        assert y_ray[0] == y_ray[1] == 2.0

Aufgabe 3: Führe Tests aus und iteriere

$ uv run pytest tests/test_geometry.py -v

# Bei Fehlern:
# 1. Ist der Test falsch? Fixe den Test.
# 2. Ist der Code falsch? Fixe den Code.
# 3. Unsicher? Füge print-Statement im Test hinzu um tatsächliche Werte zu sehen.

Erfolgskriterien:


5. Teil 5: Modul- und Integrationstests

5.1 Was sind Modul-/Integrationstests?

Unit-Test: Testet eine Funktion isoliert.

Modul-/Integrationstest: Testet mehrere Module die zusammenarbeiten.

Beispiel:

# Unit-Test: Teste NUR geometry.find_intersection()
def test_find_intersection():
    x_road = np.array([0, 10])
    y_road = np.array([0, 2])
    x, y, dist = find_intersection(x_road, y_road, -10.0)
    assert x is not None

# Integrationstest: Teste geometry + road zusammen
def test_road_generation_with_intersection() -> None:
    """Teste dass Road-Generierung Daten produziert die Geometry verarbeiten kann."""
    from road_profile_viewer.road import generate_road_profile
    from road_profile_viewer.geometry import find_intersection

    # Generiere Road mittels road.py
    x_road: NDArray[np.float64]
    y_road: NDArray[np.float64]
    x_road, y_road = generate_road_profile(num_points=100, x_max=80)

    # Nutze geometry.py um Schnittpunkt zu finden
    x, y, dist = find_intersection(x_road, y_road, -10.0, camera_x=0.0, camera_y=2.0)

    # Verifiziere dass Integration funktioniert
    assert x is not None, "Generierte Road sollte Kamera-Strahl schneiden"
    assert 0 <= x <= 80, "Schnittpunkt sollte innerhalb Road-Grenzen sein"
    assert y >= 0, "Schnittpunkt sollte über Boden sein"

Kernunterschied:

5.2 Wann Integrationstests schreiben

Schreibe Integrationstests um zu fangen:

  1. Interface-Mismatches
    # road.py returned (x, y)
    # geometry.py erwartet (x, y)
    # Falls road.py zu return (x, y, metadata) ändert, fangen Tests es!
    
  2. Datenformat-Probleme
    # Was wenn generate_road_profile() list statt np.array returned?
    # Integrationstest wird fehlschlagen!
    
  3. Annahmen über Daten
    # geometry.py nimmt an dass x_road sortiert ist
    # Was wenn road.py unsortierte Daten returned?
    # Integrationstest fängt das!
    

5.3 Beispiel Integrationstests-Suite

# tests/test_integration.py
import numpy as np
from numpy.typing import NDArray
import pytest
from road_profile_viewer.road import generate_road_profile
from road_profile_viewer.geometry import find_intersection, calculate_ray_line


class TestRoadGeometryIntegration:
    """Test Integration zwischen road und geometry Modulen."""

    def test_generated_road_intersects_downward_ray(self) -> None:
        """Verifiziere dass generierte Roads von Geometry-Funktionen verarbeitet werden können."""
        # Nutze echte Road-Generierung
        x_road: NDArray[np.float64]
        y_road: NDArray[np.float64]
        x_road, y_road = generate_road_profile(num_points=100, x_max=80)

        # Verifiziere dass es mit Geometry-Modul funktioniert
        x, y, dist = find_intersection(x_road, y_road, -10.0, camera_x=0.0, camera_y=10.0)

        assert x is not None, "Sollte Schnittpunkt mit generierter Road finden"
        assert isinstance(x, (int, float, np.number)), "Sollte numerischen Typ returnen"
        assert isinstance(y, (int, float, np.number)), "Sollte numerischen Typ returnen"
        assert isinstance(dist, (int, float, np.number)), "Sollte numerischen Typ returnen"

    def test_road_data_format_compatible_with_geometry(self) -> None:
        """Verifiziere dass road.py Daten im Format returned das geometry.py erwartet."""
        x_road: NDArray[np.float64]
        y_road: NDArray[np.float64]
        x_road, y_road = generate_road_profile()

        # Prüfe Datentypen
        assert isinstance(x_road, np.ndarray), "x_road sollte numpy array sein"
        assert isinstance(y_road, np.ndarray), "y_road sollte numpy array sein"

        # Prüfe dass Längen matchen
        assert len(x_road) == len(y_road), "Road arrays sollten gleiche Länge haben"

        # Prüfe dass Daten sortiert sind
        assert np.all(np.diff(x_road) > 0), "x_road sollte strikt aufsteigend sein"

    def test_varying_road_parameters_work_with_geometry(self) -> None:
        """Teste dass unterschiedliche Road-Generierungs-Parameter mit Geometry funktionieren."""
        for num_points in [10, 50, 100, 200]:
            for x_max in [40, 80, 120]:
                x_road, y_road = generate_road_profile(num_points, x_max)
                x, y, dist = find_intersection(x_road, y_road, -10.0)
                # Sollte nicht crashen, findet möglicherweise Schnittpunkt oder nicht
                assert x is None or isinstance(x, (int, float, np.number))

Führe Integrationstests aus:

$ uv run pytest tests/test_integration.py -v

5.4 E2E-Testing: Warum wir es (vorerst) überspringen

End-to-End Test würde bedeuten:

  1. Starte die Dash-Applikation
  2. Öffne einen Browser (mittels Selenium/Playwright)
  3. Gib Winkel in Eingabefeld ein
  4. Verifiziere dass Graph korrekt aktualisiert

Warum in diesem Kurs überspringen?

  1. Komplexes Setup: Benötigt Selenium, Browser-Treiber, etc.
  2. Langsam: Jeder Test braucht Sekunden zum Ausführen
  3. Brüchig: UI-Änderungen brechen Tests häufig
  4. Diminishing Returns: Unit + Integrationstests fangen 90% der Bugs

Für diesen Kurs:

In echten Projekten: Ja, E2E-Tests sind wichtig. Aber machen Sie Ihre Pyramiden-Basis erst solide!


6. 5.5 Test-Wartbarkeit: Tests schreiben die nicht brechen

Sie haben gelernt wie man Unit-Tests, Integrationstests schreibt und wie man LLMs zum Beschleunigen von Testing nutzt. Jetzt sprechen wir über eine kritische Qualität: Test-Wartbarkeit.

Das Problem:

Sie schreiben 20 Unit-Tests. Sie bestehen alle. ✅

Sie refactoren find_intersection() um Performance zu verbessern (keine Verhaltensänderung). Plötzlich schlagen 10 Tests fehl. ❌

Frage: Ist das gut oder schlecht?

Schlecht! Diese Tests sind brüchig - sie brechen wenn sich Implementierung ändert, obwohl das Verhalten gleich blieb.


6.1 Das Brüchigkeits-Problem

Brüchige Tests schlagen fehl als Reaktion auf unabhängige Produktionscode-Änderungen die keine echten Bugs einführen. Sie:

Googles Erkenntnis: In großen Codebasen sind brüchige Tests ein großer Produktivitätskiller. Engineers verbringen mehr Zeit mit Fixen von Tests als mit Fixen echter Bugs!


6.2 Beispiel: Brüchiger Test (Testet Implementierungsdetails)

# ❌ BRÜCHIG: Testet WIE die Funktion intern arbeitet
from unittest.mock import patch

def test_find_intersection_uses_tan_for_slope():
    """Teste dass Funktion np.tan nutzt um Steigung zu berechnen"""
    x_road = np.array([0, 10, 20], dtype=np.float64)
    y_road = np.array([0, 2, 4], dtype=np.float64)

    # Mocke np.tan um zu verifizieren dass es aufgerufen wird
    with patch('numpy.tan') as mock_tan:
        mock_tan.return_value = 0.176  # Gemockter Steigungs-Wert
        find_intersection(x_road, y_road, -10.0)
        assert mock_tan.called  # ❌ Bricht wenn Sie Steigungs-Berechnung ändern!

Problem: Falls Sie zu einem anderen Trigonometrie-Ansatz refactoren (z.B. mittels atan2 oder vorberechneten Lookup-Tables), schlägt dieser Test fehl obwohl:

Dieser Test testet die IMPLEMENTIERUNG, nicht das VERHALTEN.


6.3 Besser: Robuster Test (Testet öffentliches Verhalten)

# ✅ ROBUST: Testet WAS der Code tut, nicht WIE er es tut
def test_find_intersection_returns_correct_position_for_downward_angle():
    """Teste dass Schnittpunkt-Position geometrisch korrekt ist für Abwärts-Strahl"""
    # Arrange
    x_road = np.array([0, 10, 20], dtype=np.float64)
    y_road = np.array([0, 2, 4], dtype=np.float64)

    # Act
    x, y, dist = find_intersection(x_road, y_road, -10.0, 0.0, 10.0)

    # Assert behavior: Schnittpunkt sollte innerhalb Road-Grenzen sein
    assert x is not None, "Sollte Schnittpunkt für Abwärtswinkel finden"
    assert 0 <= x <= 20, f"Schnittpunkt x sollte in Road-Grenzen [0, 20] sein, bekam {x}"
    assert 0 <= y <= 4, f"Schnittpunkt y sollte in Road-Grenzen [0, 4] sein, bekam {y}"
    assert dist > 0, f"Distanz sollte positiv sein, bekam {dist}"

    # Keine Annahmen über WIE es das berechnet hat!
    # Funktioniert mit tan, atan2, Lookup-Tables, oder jeder anderen Implementierung

Vorteile:


6.4 Das Schlüsselprinzip: Teste via öffentliche APIs

Googles Richtlinie: “Schreibe Tests die das zu testende System auf gleiche Weise aufrufen wie es dessen Nutzer tun würden.”

Was bedeutet das für find_intersection()?

Die öffentliche API (was Nutzer sehen):

x, y, dist = find_intersection(x_road, y_road, angle_degrees, camera_x, camera_y)

Öffentlicher Vertrag (was Nutzer erwarten):

✅ TESTE:

❌ TESTE NICHT:


6.5 Vier Kategorien von Code-Änderungen (Googles Framework)

Google kategorisiert Produktionscode-Änderungen und wie Tests reagieren sollten:

Änderungs-Typ Beispiel Sollten Tests sich ändern? Warum
Pures Refactoring Optimiere find_intersection() Algorithmus ❌ NEIN Tests verifizieren dass Verhalten konstant bleibt
Neue Features Füge find_all_intersections() Funktion hinzu ✅ Füge nur neue Tests hinzu Existierende Tests bleiben unverändert
Bug-Fixes Fixe Crash bei leeren Arrays ✅ Füge neuen Test-Case hinzu Teste den Bug um Regression zu verhindern
Verhaltensänderungen Returned Liste von Schnittpunkten statt erstem ✅ Modifiziere existierende Tests Vertrag änderte sich, Tests müssen neues Verhalten reflektieren

Das Ideal: Tests ändern sich nur wenn Verhalten sich ändert (Kategorie 4). Alle anderen Änderungen sollten Tests unberührt lassen!


6.6 Real-World Beispiel: Refactoring-Szenario

Szenario: Sie wollen find_intersection() optimieren indem Sie einen schnelleren Algorithmus nutzen.

Vorher (aktuelle Implementierung):

def find_intersection(x_road, y_road, angle_degrees, camera_x=0, camera_y=1.5):
    """Finde Schnittpunkt mittels linearer Suche"""
    angle_rad = -np.deg2rad(angle_degrees)

    # Prüfe vertikal
    if np.abs(np.cos(angle_rad)) < 1e-10:
        return None, None, None

    slope = np.tan(angle_rad)

    # Lineare Suche durch Segmente
    for i in range(len(x_road) - 1):
        x1, y1 = x_road[i], y_road[i]
        x2, y2 = x_road[i + 1], y_road[i + 1]
        # ... Schnittpunkt-Berechnung

Nachher (optimierte Implementierung):

def find_intersection(x_road, y_road, angle_degrees, camera_x=0, camera_y=1.5):
    """Finde Schnittpunkt mittels binärer Suche (10x schneller!)"""
    angle_rad = -np.deg2rad(angle_degrees)

    # Andere Vertikal-Prüfung mittels Sinus
    if np.abs(np.sin(angle_rad - np.pi/2)) < 1e-10:  # Geändert!
        return None, None, None

    # Nutze atan2 statt tan (numerisch stabiler)
    direction = np.array([np.cos(angle_rad), np.sin(angle_rad)])  # Geändert!

    # Binäre Suche durch Segmente (schneller für große Roads)
    left, right = 0, len(x_road) - 1
    # ... binäre Such-Logik (komplett anders!)

Was passiert mit Tests?

❌ Brüchige Tests (testen Implementierung):

def test_find_intersection_uses_tan():
    with patch('numpy.tan') as mock_tan:
        find_intersection(...)
        assert mock_tan.called  # ❌ SCHLÄGT FEHL! Wir nutzen atan2 jetzt!

✅ Robuste Tests (testen Verhalten):

def test_find_intersection_returns_correct_position():
    x, y, dist = find_intersection(x_road, y_road, -10.0)
    assert 0 <= x <= 20  # ✅ BESTEHT! Findet noch korrekten Schnittpunkt!

Ergebnis:

Das ist der Unterschied zwischen produktivem Testing und Test-Hölle!


6.7 Praktische Richtlinien für wartbare Tests

✅ TUE:

  1. Teste nur öffentliche API - Rufe Funktionen auf gleiche Weise auf wie Nutzer es tun würden
  2. Teste finale Ergebnisse - Assert auf Return-Values, nicht internen State
  3. Nutze echte Objekte wenn schnell - Vermeide Mocking von NumPy, Math-Funktionen
  4. Teste Verhalten, nicht Methoden - Ein Test pro Verhalten (nicht einer pro Funktion)
  5. Erwarte dass Tests stabil sind - Gute Tests ändern sich nur wenn Requirements sich ändern

❌ TUE NICHT:

  1. Mocken Sie interne Implementierung - Mocken Sie nicht np.tan() in Ihrem eigenen Code
  2. Assertiere auf privaten State - Prüfe nicht interne Variablen
  3. Teste Methoden-Aufruf-Sequenzen - Verifiziere nicht dass tan vor cos aufgerufen wurde
  4. Koppele Tests an Algorithmus - Nimm nicht linear vs. binäre Suche an
  5. Teste Performance in Unit-Tests - Speed ist getrennt von Korrektheit

6.8 Zusammenfassung: Wartbare vs. Brüchige Tests

Aspekt Wartbare Tests Brüchige Tests
Was sie testen Öffentliches Verhalten (WAS) Implementierungsdetails (WIE)
Assertion-Stil assert x is not None mock_tan.assert_called()
Refactoring-Auswirkung Tests bestehen noch ✅ Tests brechen ❌
Developer-Erfahrung "Tests funktionieren einfach!" "Ugh, Tests wieder fixen..."
Wann sie fehlschlagen Echter Bug gefunden Oft Fehlalarm

Kern-Erkenntnis: Die besten Tests sind die, die Sie nie anfassen müssen, bis ein echter Bug erscheint. Testen Sie WAS Ihr Code tut, nicht WIE er es tut!


6. Teil 6: Feature-Branch Workflow anwenden

Lass uns diese Tests mittels professionellem Workflow aus Chapter 02 (Feature Development) hinzufügen!

6.1 Schritt 1: Erstelle Feature-Branch

$ git checkout main
$ git pull origin main
$ git checkout -b feature/add-unit-tests

6.2 Schritt 2: Erstelle Test-Files

$ mkdir -p tests
$ touch tests/__init__.py
$ touch tests/test_geometry.py
$ touch tests/test_road.py
$ touch tests/test_integration.py

6.3 Schritt 3: Schreibe Tests (mittels LLM-Unterstützung)

Nutze Copilot/Claude um initiale Tests zu generieren, dann verfeinere:

# tests/test_geometry.py
# (Siehe Beispiele aus Teil 5 oben)

# tests/test_road.py
import numpy as np
from numpy.typing import NDArray
import pytest
from road_profile_viewer.road import generate_road_profile


class TestGenerateRoadProfile:
    def test_default_parameters(self) -> None:
        """Teste Road-Generierung mit Default-Parametern."""
        x: NDArray[np.float64]
        y: NDArray[np.float64]
        x, y = generate_road_profile()
        assert len(x) == 100  # Default num_points
        assert x[0] == 0.0
        assert y[0] == 0.0  # Road startet bei Origin

    def test_custom_num_points(self) -> None:
        """Teste Road-Generierung mit custom Anzahl an Punkten."""
        x, y = generate_road_profile(num_points=50)
        assert len(x) == 50
        assert len(y) == 50

    def test_road_is_continuous(self) -> None:
        """Verifiziere dass generierte Road keine Lücken hat."""
        x, y = generate_road_profile(num_points=100)
        assert np.all(np.diff(x) > 0), "x sollte strikt aufsteigend sein"

6.4 Schritt 4: Führe Tests lokal aus

$ uv run pytest tests/ -v
============================= test session starts ==============================
tests/test_geometry.py::TestFindIntersection::test_normal_downward_angle PASSED
tests/test_geometry.py::TestFindIntersection::test_empty_road_arrays PASSED
tests/test_road.py::TestGenerateRoadProfile::test_default_parameters PASSED
...
============================== 15 passed in 0.25s ===============================

6.5 Schritt 5: Committen Sie Ihre Tests

$ git add tests/
$ git commit -m "Add unit tests for geometry and road modules

- test_geometry.py: 10 tests covering equivalence classes and boundaries
- test_road.py: 5 tests for road generation
- test_integration.py: 3 integration tests
- All tests use AAA pattern (Arrange-Act-Assert)
- Tests generated with LLM assistance, refined by human review

Total: 18 tests, all passing"

6.6 Schritt 6: Pushe und erstelle PR

$ git push -u origin feature/add-unit-tests

$ gh pr create --title "Add unit tests for geometry and road modules" \
  --body "Adds comprehensive test suite using pytest.

**Coverage:**
- geometry.py: 10 unit tests (equivalence classes + boundaries)
- road.py: 5 unit tests
- Integration: 3 tests verifying modules work together

**Testing approach:**
- Used LLM to generate initial test structure
- Human review to fix assertions and add edge cases
- All tests pass locally

**Next steps:**
- Chapter 03 (TDD and CI) will add CI integration
- TDD workflow for new features"

6.7 Schritt 7: CI wird ausführen (in Chapter 03)

In der nächsten Lecture werden wir CI updaten um Tests automatisch auszuführen!


7. Zusammenfassung: Was Sie erreicht haben

7.1 Vor dieser Lecture

✅ Ruff (Style)
✅ Pyright (Typen)
❌ Keine Tests → Logik-Bugs schlüpfen durch

7.2 Nach dieser Lecture

✅ Ruff (Style)
✅ Pyright (Typen)
✅ Pytest (Korrektheit) → 18 Tests fangen Bugs!

7.3 Schlüssel-Konzepte die Sie gemeistert haben

1. Testing-Pyramide

2. Unit-Testing Skills

3. LLM-unterstütztes Testing

4. Praktische Skills

7.4 Der Unterschied den Tests machen

Ohne Tests:

Developer: *Ändert find_intersection()*
Developer: *Testet manuell durchs Klicken der UI*
Developer: "Sieht gut aus!" *Pusht zu main*
User: *Entdeckt Bug bei vertikalem Winkel* "App crashed!"

Mit Tests:

Developer: *Ändert find_intersection()*
Developer: $ pytest tests/
Developer: "❌ Test fehlgeschlagen! Bug vor Commit gefangen"
Developer: *Fixt Bug, Tests bestehen*
Developer: *Pusht mit Confidence*
User: *Alles funktioniert!*

8. Kern-Erkenntnisse

Erinnern Sie sich an diese Prinzipien:

  1. Code-Qualität ≠ Code-Korrektheit - Ruff fängt Style, Tests fangen Bugs
  2. Testing-Pyramide, nicht Cone - Mehr Unit-Tests, weniger E2E-Tests
  3. Äquivalenzklassen + Boundaries - Teste smart, nicht exhaustiv
  4. AAA-Pattern - Arrange, Act, Assert für lesbare Tests
  5. LLMs assistieren, Menschen verifizieren - Nutze LLMs für Boilerplate, nicht Korrektheit
  6. Schnelle Tests = häufiges Testing - Unit-Tests laufen in Millisekunden
  7. Tests geben Confidence - Refactore ohne Angst
  8. Feature-Branch Workflow gilt für Tests - Tests sind auch ein Feature!

Sie sind jetzt bereit für Chapter 03 (TDD and CI): Test-Driven Development!

In der nächsten Lecture werden wir das Skript flippen: schreibe Tests VOR dem Code (TDD), integriere Tests in CI, und lass fehlschlagende Tests Merges blockieren.


9. Weiterführende Literatur

Über Testing:

Über die Testing-Pyramide:

Über Äquivalenzklassen:

Interaktives Lernen:


Letzte Aktualisierung: 2025-11-04 Voraussetzungen: Lectures 1-4 (Besonders Chapter 02 (Refactoring) - Modularer Code), Chapter 03 (Testing Basics) Nächste Lecture: Chapter 03 (TDD and CI) - Test-Driven Development & CI Integration

© 2026 Dominik Mueller   •  Powered by Soopr   •  Theme  Moonwalk