03 Grundlagen des Testens: Boundary Analysis und LLM-Assisted Testing
November 2025 (19034 Words, 106 Minutes)
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:
- 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
- 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!
- Unit-Testing-Grundlagen
- AAA-Pattern: Arrange-Act-Assert
- Warum pytest der Industriestandard ist
- Ihren ersten Unit-Test schreiben
- Die Unmöglichkeit von exhaustivem Testing
- 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
- Ä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:
- “Woher weiß ich, ob ich genug getestet habe?”
- “Was ist mit diesen kniffligen Edge Cases an den Grenzen?”
- “20-30 Tests für eine Funktion zu schreiben klingt mühsam!”
- “Kann ich KI nutzen, um Tests schneller zu schreiben?”
Die Mission heute: Von Testing-Grundlagen zur Testing-Meisterschaft
In Teil 2 werden wir diese Probleme mit drei mächtigen Techniken lösen:
- Boundary Value Analysis - Wo Bugs sich tatsächlich verstecken (nicht in der Mitte von Äquivalenzklassen!)
- LLM-Assisted Testing - Den “Test Cone” durchbrechen, indem wir KI für Boilerplate nutzen (mit menschlicher Aufsicht)
- 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:
- Grenzwerte systematisch für jede Funktion zu identifizieren
- LLMs zu nutzen, um Test-Boilerplate zu generieren (während Sie häufige KI-Fallstricke vermeiden)
- Integrationstests zu schreiben, die verifizieren, dass Module zusammenarbeiten
- Feature-Branch-Workflow anzuwenden, um Tests zu Ihrem Road Profile Viewer hinzuzufügen
- Zu wissen, wann Sie “genug” Tests haben (realistische Coverage, nicht Perfektion)
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:
- Wie Zahlen binär gespeichert werden (Vorzeichen, Exponent, Mantisse)
- Spezielle Werte (Unendlich, NaN)
- Rundungsverhalten
- Operationen (+, -, ×, ÷)
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:
- IEEE 754 Standard: https://standards.ieee.org/standard/754-2019.html
- Wikipedia (Guter Überblick): https://en.wikipedia.org/wiki/IEEE_754
- What Every Computer Scientist Should Know About Floating-Point Arithmetic: https://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html
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:
math.inf(oderfloat('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)
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:
sys.float_info.minist NICHT die negativste Zahl (das wäre-sys.float_info.max)- Es ist die kleinste positive normalisierte Zahl (am nächsten zu Null ohne denormalisiert zu sein)
- Ähnlich wie C’s
DBL_MINfunktioniert - Es gibt KEIN
sys.float_info.min_negative- nutze einfach-sys.float_info.max
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:
- Code-Duplikation (gleiche Testlogik wiederholt)
- Schwer zu warten (Logik ändern → 6 Tests updaten)
- Fügt Unordnung hinzu (6 Testfunktionen für ein Konzept)
- Wenn einer fehlschlägt, wissen Sie nicht, wie viele andere auch fehlschlagen würden
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:
- Decorator-Syntax:
@pytest.mark.parametrize("param1, param2", [...])- Erstes Argument: Parameternamen (komma-separierter String)
- Zweites Argument: Liste von Tupeln (ein Tupel pro Testfall)
- Testfunktion empfängt Parameter:
def test_function(param1, param2):- pytest injiziert die Werte jedes Tupels in die Funktion
- 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:
- ✅ DRY-Prinzip: Testlogik einmal geschrieben, für alle Fälle wiederverwendet
- ✅ Einfach Fälle hinzuzufügen: Einfach ein Tupel zur Liste hinzufügen
- ✅ Klarer Output: Jede Kombination ist ein separates Testergebnis
- ✅ Wartbar: Logik an einer Stelle ändern
- ✅ Lesbar: Parameternamen dokumentieren, was getestet wird
Wann parametrize verwenden:
✅ BENUTZE wenn:
- Dieselbe Funktion mit mehreren Inputs getestet wird
- Äquivalenzklassen dieselbe Validierungslogik haben
- Boundary-Werte demselben Muster folgen
❌ BENUTZE NICHT wenn:
- Jeder Test unterschiedliche Assertions braucht (nutze separate Testfunktionen)
- Test-Setup/Teardown pro Fall unterschiedlich ist (nutze Fixtures stattdessen)
- Tests durch komplexe Conditionals schwer lesbar werden
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:
- ✅ Teste SOWOHL endliche ALS AUCH nicht-endliche Boundaries:
sys.float_info.max(endlich) UNDmath.inf(nicht-endlich) sind unterschiedlich! - ✅ Nutze Konstanten für Lesbarkeit: Definiere
FMAX,INF, etc. auf Modul-Ebene - ✅ Bevorzuge
math.infgegenüberfloat('inf'): Bessere Lesbarkeit (gleiches mitmath.nan) - ✅ Nutze
math.nextafter()für exakte Übergänge: Teste den exakten Punkt, wo sich Verhalten ändert - ✅ Python handhabt inf/nan elegant: Keine Crashes, aber Verhalten kann überraschen
- ✅ Nutze
math.isnan()um auf NaN zu prüfen:nan == nanist immerFalse! - ✅ Dokumentiere WARUM jeder Test wichtig ist: Zukünftige Entwickler brauchen Kontext
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:
- 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!" - 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}" - 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
- ✅ Unit-Tests: Nutze kleine Arrays (3-100 Elemente) - schnell und ausreichend
- ✅ Boundary-Tests: Teste immer 0, 1, 2 Elemente (Edge-Cases)
- ✅ Performance-Tests: Nutze große Arrays (10k+) in separater Test-Suite
- ✅ Property-based Tests: Nutze Hypothesis um variierende Größen automatisch zu generieren
- ❌ Nicht: Große Arrays in reguläre Unit-Tests packen (verlangsamt Entwicklung)
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:
- Teste an der Boundary, knapp innerhalb und knapp außerhalb
- Winkel = 90°, 89.9°, 90.1°
- Länge = 0, 1, 2
- Gleitkomma-Präzision ist wichtig
- Nutze
pytest.approx()für Gleitkomma-Vergleiche - Teste Werte nahe Null (1e-10, 1e-15)
- Nutze
- Array-Boundaries sind sowohl strukturell als auch positional
- Längen-Boundaries (leer, einzeln, zwei)
- Positions-Boundaries (erstes Element, letztes Element, Grenzen dazwischen)
- 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) | 0°, ±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:
- Welche Werte sind “nahe Null”? (1e-10? 1e-100?)
- Wo testen wir Präzisionsverlust? (epsilon?)
- Wie behandeln wir Infinity und NaN?
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:
score >= 90→ Note “A” (Boundary bei 90)score >= 80→ Note “B” (Boundary bei 80)score >= 70→ Note “C” (Boundary bei 70)score >= 60→ Note “D” (Boundary bei 60)score < 60→ Note “F” (Boundary bei 60 von unten)score < 0 or score > 100→ Fehler (Boundaries bei 0 und 100)
Ä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:
- Schwellenwert-Boundaries - Wo die Ausgabekategorie wechselt:
score = 89sollte “B” zurückgebenscore = 90sollte “A” zurückgeben- Diese sind kritisch! Off-by-One-Fehler sind häufig (
>statt>=)
- 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:
- Jeder Test deckt genau eine Boundary ab - Macht es einfach zu erkennen, welche Boundary kaputt ist, wenn ein Test fehlschlägt
- Wir testen beide Seiten jedes Schwellenwerts -
score=89(B) undscore=90(A) - Wir testen gültige Bereichsgrenzen - Minimum (0), Maximum (100) und knapp außerhalb (-1, 101)
- Total: 6 Boundary-Tests - Viel fokussierter als alle 100 möglichen Punktzahlen zu testen!
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:
- Explizite Schwellenwerte - Der Code sagt buchstäblich
if score >= 90, also weiß man, dass man 89 vs 90 testen muss - Keine Präzisionssorgen - Ganzzahl-Schwellenwerte bedeuten keine Fließkomma-Edge-Cases
- Klares Pass/Fail - Entweder ist die Note korrekt oder nicht - keine “nah genug”-Entscheidungen
- 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:
- 4 Gewichts-Boundaries × 4 Entfernungs-Boundaries × 2 Express-Werte = 32 Tests
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:
- Unabhängiges Dimensionstesten - Jede Dimension wird getestet, während andere konstant gehalten werden
- Total: ~12 Tests statt 32 (alle Kombinationen) oder 100+ (erschöpfend)
- Wir fangen trotzdem Boundary-Bugs - Wenn Gewichts-Schwellenwert falsch ist (
weight < 5stattweight <= 5), schlägt unser Test fehl - Klare Testnamen - Jeder Test gibt explizit an, welche Boundary getestet wird
Wann Kombinationen testen:
Kombinationstests sollten hinzugefügt werden, wenn:
- Dimensionen interagieren - Z.B. “schwere Pakete bekommen Rabatt auf lange Entfernungen”
- Edge-Case-Kombinationen - Z.B. “Mindestgewicht + Maximalentfernung + Express”
- Kritische Geschäftslogik - Z.B. “kostenloser Versand für leichte lokale Pakete”
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:
- Boundaries sind explizit - Suche nach Schwellenwerten in bedingter Logik (
if,elif) - Teste beide Seiten jedes Schwellenwerts -
wert-1(alte Kategorie) vswert(neue Kategorie) - Teste Bereichsgrenzen - Minimal gültig, maximal gültig, knapp außerhalb des Bereichs
- 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 >= 90sieht, weiß man, dass manx=89undx=90testen muss. Wenn manif weight <= 5sieht, weiß man, dass manweight=5.0undweight=5.1testen 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:
- “Tests schreiben ist langweilig” (42% der Entwickler)
- Boilerplate: imports, setup, teardown
- Repetitiv: Ähnliche Struktur für jede Funktion
- “Ich weiß nicht was ich testen soll” (38% der Entwickler)
- Was sind die Äquivalenzklassen?
- Was sind die Boundary-Cases?
- Wie viele Tests brauche ich?
- “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:
- ✅ Boilerplate (imports, Test-Struktur, AAA-Pattern)
- ✅ Standardmuster (Return-Werte testen, grundlegende Assertions)
- ✅ Coverage (Tests für jede Funktion generieren)
Was LLMs SCHLECHT können:
- ❌ Domänenwissen (“Ist es korrekt dass vertikale Winkel None zurückgeben?”)
- ❌ Edge-Cases spezifisch für Ihr Problem (“Was wenn Kamera in der Road ist?”)
- ❌ Subtile Bugs in Testlogik (Test besteht immer auch wenn er nicht sollte)
- ❌ Korrekte erwartete Werte bestimmen (“Was SOLLTE die Distanz sein?”)
- ❌ Unnötige Logik zu Tests hinzufügen (Schleifen, Conditionals, Berechnungen)
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:
- Schleife macht es schwer zu sehen welcher Winkel fehlschlug
- Conditional-Logik bedeutet Test testet möglicherweise gar nichts
- Wenn Test fehlschlägt, welcher Winkel war das Problem?
- Der Test selbst braucht Testing!
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)
- ✅ MACHE: Schreibe einfachen, linearen Testcode
- ✅ MACHE: Nutze pytest’s
@pytest.mark.parametrizefür mehrere Inputs - ❌ NICHT: Schleifen in Tests schreiben
- ❌ NICHT: Conditionals in Tests schreiben
- ❌ NICHT: Erwartete Werte in Tests berechnen (nutze hart-codierte Werte)
Warum?
- Tests müssen trivial korrekt durch Inspektion sein
- Wenn Ihr Test Logik hat, brauchen Sie Tests für Ihre Tests!
- Komplexe Testlogik versteckt Bugs im Test selbst
- Wenn ein Test fehlschlägt, wollen Sie sofort wissen was falsch ist
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:
- State Testing: Beobachte das System nach dem Aufruf um Outcomes zu verifizieren
- 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:
- Die Trigonometrie-Implementierung änderst (z.B. cosine/sine statt tan nutzt)
- Die Steigungsberechnung optimierst
- Eine andere Math-Library nutzt
- Aber die Funktion produziert immer noch korrekte Ergebnisse!
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:
- ✅ Testet Verhalten, nicht Implementierung
- ✅ Überlebt Refactoring (Sie können WIE es berechnet ändern, solange WAS es zurückgibt korrekt ist)
- ✅ Klar was getestet wird (Intersektions-Position und Distanz)
- ✅ Schlägt nur fehl wenn tatsächliches Verhalten sich ändert
3.1.3 Wann IST Mocking angemessen?
Mocking ist wertvoll in spezifischen Szenarien:
✅ MOCKE:
- Externe APIs (Netzwerk-Calls, HTTP-Requests)
- Datenbanken (langsam, brauchen Setup)
- Datei-I/O (langsam, braucht Filesystem-State)
- Zeit/Zufall (nicht-deterministisches Verhalten)
- Teure Operationen (braucht Sekunden/Minuten zum Ausführen)
❌ MOCKE NICHT:
- Pure Functions (wie
find_intersection()- deterministisch, schnell) - Einfache Datenstrukturen (NumPy-Arrays, Listen, Dicts)
- Interne Implementierungsdetails (welche Funktionen Ihr Code aufruft)
- Math-Libraries (NumPy, math-Modul - sie sind schnell und deterministisch)
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:
- Externe API ist langsam und braucht Netzwerk
- API könnte rate-limited sein oder Geld kosten
- API ist möglicherweise nicht in Test-Umgebung verfügbar
- Wir testen immer noch das ERGEBNIS (temp == 22.5), nicht Methodenaufrufe
3.1.5 Faustregel: Bevorzuge echte Objekte über Mocks
Für find_intersection() und ähnliche Funktionen:
- ✅ Nutze echte NumPy-Arrays - sie sind schnell (Mikrosekunden) und deterministisch
- ✅ Nutze echte Math-Funktionen -
np.tan(),np.cos()sind instant - ✅ Teste den finalen State - Intersektions-Koordinaten, Distanzwerte
- ❌ Mocke nicht NumPy - kein Vorteil, fügt Brüchigkeit hinzu
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:
- ✅ Mindestens 10 Tests für
find_intersection() - ✅ Mindestens 5 Tests für
calculate_ray_line() - ✅ Alle Äquivalenzklassen abgedeckt
- ✅ Boundary-Fälle getestet
- ✅ Alle Tests bestehen
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:
- Unit-Test: Mock/fake Daten (
np.array([0, 10])) - Integrationstest: Echte Daten aus tatsächlichen Modulen (
generate_road_profile())
5.2 Wann Integrationstests schreiben
Schreibe Integrationstests um zu fangen:
- Interface-Mismatches
# road.py returned (x, y) # geometry.py erwartet (x, y) # Falls road.py zu return (x, y, metadata) ändert, fangen Tests es! - Datenformat-Probleme
# Was wenn generate_road_profile() list statt np.array returned? # Integrationstest wird fehlschlagen! - 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:
- Starte die Dash-Applikation
- Öffne einen Browser (mittels Selenium/Playwright)
- Gib Winkel in Eingabefeld ein
- Verifiziere dass Graph korrekt aktualisiert
Warum in diesem Kurs überspringen?
- Komplexes Setup: Benötigt Selenium, Browser-Treiber, etc.
- Langsam: Jeder Test braucht Sekunden zum Ausführen
- Brüchig: UI-Änderungen brechen Tests häufig
- Diminishing Returns: Unit + Integrationstests fangen 90% der Bugs
Für diesen Kurs:
- ✅ 70% Unit-Tests (schnell, fokussiert)
- ✅ 20% Integrationstests (moderat)
- ❌ 10% E2E-Tests (überspringen - zu fortgeschritten)
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:
- Zwingen Sie wiederholt Tests zu tweaken bei jedem Refactoring
- Verbrauchen überproportional viel Wartungszeit
- Skalieren schlecht in großen Codebasen
- Untergraben die “automatisierte” Natur von Test-Suites
- Machen Sie ängstlich vor Refactoring
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:
- Die Schnittpunkt-Position noch korrekt ist
- Das Verhalten identisch ist
- Keine Bugs eingeführt wurden
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:
- ✅ Testet Verhalten, nicht Implementierung
- ✅ Überlebt Refactoring (ändere WIE, Verhalten bleibt gleich)
- ✅ Klar was getestet wird (Schnittpunkt-Korrektheit)
- ✅ Schlägt nur fehl wenn tatsächliches Verhalten sich ändert
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):
- Input: Arrays und Winkel
- Output: Schnittpunkt-Koordinaten oder None
- Verhalten: Finde wo Kamera-Strahl Road schneidet
✅ TESTE:
- Returned es korrekte Schnittpunkt-Koordinaten?
- Handhabt es Edge-Cases (leere Arrays, vertikale Winkel)?
- Returned es None wenn Strahl Road verfehlt?
❌ TESTE NICHT:
- Ruft es
np.tan()intern auf? - Nutzt es einen spezifischen Algorithmus?
- In welcher Reihenfolge verarbeitet es Road-Segmente?
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:
- Brüchige Tests: 10 Fehlschläge (alles False Positives!)
- Robuste Tests: 0 Fehlschläge (Verhalten unverändert!)
Das ist der Unterschied zwischen produktivem Testing und Test-Hölle!
6.7 Praktische Richtlinien für wartbare Tests
✅ TUE:
- Teste nur öffentliche API - Rufe Funktionen auf gleiche Weise auf wie Nutzer es tun würden
- Teste finale Ergebnisse - Assert auf Return-Values, nicht internen State
- Nutze echte Objekte wenn schnell - Vermeide Mocking von NumPy, Math-Funktionen
- Teste Verhalten, nicht Methoden - Ein Test pro Verhalten (nicht einer pro Funktion)
- Erwarte dass Tests stabil sind - Gute Tests ändern sich nur wenn Requirements sich ändern
❌ TUE NICHT:
- Mocken Sie interne Implementierung - Mocken Sie nicht
np.tan()in Ihrem eigenen Code - Assertiere auf privaten State - Prüfe nicht interne Variablen
- Teste Methoden-Aufruf-Sequenzen - Verifiziere nicht dass
tanvorcosaufgerufen wurde - Koppele Tests an Algorithmus - Nimm nicht linear vs. binäre Suche an
- 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
- 70% Unit-Tests (schnell, fokussiert)
- 20% Integrationstests (moderat)
- 10% E2E-Tests (langsam, teuer)
2. Unit-Testing Skills
- AAA-Pattern (Arrange-Act-Assert)
- Äquivalenzklassen-Partitionierung
- Boundary-Value-Analyse
- Schreiben fokussierter, schneller Tests
3. LLM-unterstütztes Testing
- LLMs generieren Boilerplate (schneller Start)
- Menschen verifizieren Korrektheit (essentiell!)
- Iterativer Verfeinerungs-Loop
- Bricht das “Test-Cone” Pattern
4. Praktische Skills
- Erstellte Test-File-Struktur
- Schrieb 18+ Unit-Tests
- Nutzte pytest um Tests auszuführen
- Wendete Feature-Branch Workflow an
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:
- Code-Qualität ≠ Code-Korrektheit - Ruff fängt Style, Tests fangen Bugs
- Testing-Pyramide, nicht Cone - Mehr Unit-Tests, weniger E2E-Tests
- Äquivalenzklassen + Boundaries - Teste smart, nicht exhaustiv
- AAA-Pattern - Arrange, Act, Assert für lesbare Tests
- LLMs assistieren, Menschen verifizieren - Nutze LLMs für Boilerplate, nicht Korrektheit
- Schnelle Tests = häufiges Testing - Unit-Tests laufen in Millisekunden
- Tests geben Confidence - Refactore ohne Angst
- 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:
- Kent Beck’s “Test-Driven Development by Example”
- Martin Fowler’s “Testing Strategies” Artikel
- pytest Dokumentation: https://docs.pytest.org/
Über die Testing-Pyramide:
- Martin Fowler: TestPyramid
- Google Testing Blog: Testing on the Toilet Serie
Über Äquivalenzklassen:
- Software Testing Fundamentals: Equivalence Partitioning
- Boundary Value Analysis Techniken
Interaktives Lernen:
- pytest Tutorial: https://docs.pytest.org/en/stable/getting-started.html
- Real Python: Effective Python Testing With Pytest
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