03 Grundlagen des Testens: Coverage Detektiv-Übung
December 2025 (7311 Words, 41 Minutes)
Neu: Interaktive Übung verfügbar!
Möchtest du deinen Fortschritt verfolgen und sofortiges Feedback erhalten? Probiere unsere neue interaktive Version mit Fortschrittsverfolgung, sofortigen Erklärungen und Punkteberechnung.
Coverage Detektiv-Übung: Reports Analysieren und Tests Entwerfen
Einführung
In dieser Übung wirst du zum Coverage-Detektiv. Du analysierst echte pytest-cov-Ausgaben, identifizierst welche Code-Pfade ungetestet sind und entwirfst Tests, um die Lücken zu schließen.
Vorbereitung: Zuerst Lesen
Bevor du diese Übung versuchst, lies die folgenden Vorlesungsabschnitte:
Aus Kapitel 03 (TDD und CI): TDD und CI:
- Teil 3: Coverage Reporting einrichten -
--cov-report=term-missingverstehen
Aus Kapitel 03 (Testtheorie und Coverage): Testtheorie & Coverage:
- Abschnitt 5: Systematischer Testentwurf - Tests für spezifische Branches entwerfen
Fokusgebiete: “Missing”-Zeilen interpretieren, Zeilen auf Bedingungen abbilden, gezielte Tests entwerfen
Lernziele:
- pytest-cov-Ausgabe interpretieren und fehlende Zeilen identifizieren
- Verstehen, warum bestimmte Zeilen nicht ausgeführt wurden
- Testfälle entwerfen, die spezifische Branches abdecken
- Testfunktionen schreiben, die Ziel-Coverage erreichen
Struktur:
- Teil 1 (Übungen 1-5): Analyse - Identifizieren was fehlt und warum
- Teil 2 (Übungen 6-10): Entwurf - Tests erstellen, um die Lücken zu schließen
Zeit: 45 Minuten gesamt (20 Min Teil 1, 25 Min Teil 2)
Teil 1: Analyse
In diesen Übungen analysierst du Coverage-Reports und identifizierst die Ursache fehlender Coverage.
Übung 1: Einfacher Fehlender Branch
Code: validate_age.py
1
2
3
4
5
6
7
8
def validate_age(age):
if age < 0:
return "invalid: negative"
if age < 18:
return "minor"
if age < 65:
return "adult"
return "senior"
Testdatei: test_validate_age.py
def test_minor():
assert validate_age(15) == "minor"
def test_adult():
assert validate_age(30) == "adult"
Coverage-Report
Name Stmts Miss Cover Missing
--------------------------------------------------
src/validate_age.py 8 2 75% 3, 8
--------------------------------------------------
Frage
Welche Bedingungen sind NICHT abgedeckt, und warum?
A) age < 0 und age >= 65, weil kein Test negative oder Senior-Alter verwendet
B) age < 18 und age < 65, weil diese Bedingungen immer fehlschlagen
C) age < 0 und age < 18, weil kein Test Alter unter 18 verwendet
D) Der Report ist falsch; alle Bedingungen sollten abgedeckt sein
Lösung anzeigen
Richtige Antwort: A
Verfolgen wir was ausgeführt wird:
test_minor(15): age < 0 ist False (überspringe Zeile 3), age < 18 ist True (return “minor”)test_adult(30): age < 0 ist False, age < 18 ist False, age < 65 ist True (return “adult”)
NICHT ausgeführte Zeilen:
- Zeile 3 (
return "invalid: negative"): Benötigt age < 0, z.B.validate_age(-5) - Zeile 8 (
return "senior"): Benötigt age >= 65, z.B.validate_age(70)
Fehlende Tests:
def test_negative_age():
assert validate_age(-5) == "invalid: negative"
def test_senior():
assert validate_age(70) == "senior"
Übung 2: Boolean-Parameter
Code: format_name.py
1
2
3
4
5
6
7
def format_name(first, last, formal=False):
if not first or not last:
return "Invalid name"
if formal:
return f"Mr./Ms. {last}"
return f"{first} {last}"
Testdatei
def test_informal_name():
assert format_name("John", "Doe") == "John Doe"
def test_formal_name():
assert format_name("Jane", "Smith", formal=True) == "Mr./Ms. Smith"
Coverage-Report
Name Stmts Miss Cover Missing
-------------------------------------------------
src/format_name.py 6 1 83% 3
-------------------------------------------------
Frage
Zeile 3 ist return "Invalid name". Warum ist sie NICHT abgedeckt?
A) Die if formal-Bedingung ist niemals True
B) Die not first or not last-Bedingung ist niemals True
C) Der formal-Parameter ist immer False
D) Die Funktion hat einen Syntaxfehler
Lösung anzeigen
Richtige Antwort: B
Analysieren wir:
test_informal_name("John", "Doe"): first=”John” (truthy), last=”Doe” (truthy)not first or not last=not "John" or not "Doe"=False or False= False → Überspringe Zeile 3
test_formal_name("Jane", "Smith", True): gleiche Logik, Zeile 3 wird übersprungen
Zeile 3 erfordert:
firstist leer/None/falsy, ODERlastist leer/None/falsy
Fehlende Tests:
def test_empty_first_name():
assert format_name("", "Doe") == "Invalid name"
def test_empty_last_name():
assert format_name("John", "") == "Invalid name"
def test_none_name():
assert format_name(None, "Doe") == "Invalid name"
Übung 3: Mehrere Bedingungen in Einer Zeile
Code: check_access.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
def check_access(user_role, resource_type, is_owner):
# Admin can access anything
if user_role == "admin":
return True
# Owner can access their own resources
if is_owner:
return True
# Editors can access documents and images
if user_role == "editor" and resource_type in ["document", "image"]:
return True
return False
Testdatei
def test_admin_access():
assert check_access("admin", "secret", False) == True
def test_owner_access():
assert check_access("viewer", "document", True) == True
def test_editor_document():
assert check_access("editor", "document", False) == True
def test_denied():
assert check_access("viewer", "document", False) == False
Coverage-Report
Name Stmts Miss Cover Missing
--------------------------------------------------
src/check_access.py 10 1 90% 12
--------------------------------------------------
Frage
Zeile 12 ist if user_role == "editor" and resource_type in ["document", "image"]:. Warum ist ein Teil dieser Zeile als teilweise abgedeckt markiert?
A) Die “editor”-Rolle wurde nie getestet
B) Die “document”-Ressource wurde nie getestet
C) Der “image”-Ressourcentyp wurde nie getestet (nur “document” wurde getestet)
D) Der is_owner-Parameter wurde nicht getestet
Lösung anzeigen
Richtige Antwort: C
Betrachten wir die Tests:
test_editor_document: user_role=”editor”, resource_type=”document” → True- Die Bedingung
resource_type in ["document", "image"]ist True für “document”
Aber “image” wurde nie getestet!
Die Liste ["document", "image"] hat zwei gültige Werte:
- “document”: Abgedeckt durch
test_editor_document - “image”: NICHT abgedeckt
Hinweis: Einige Coverage-Tools melden dies als Zeile 12 “teilweise abgedeckt” oder zeigen es in der Branch-Coverage, während Statement-Coverage 100% anzeigen könnte. Das ist der Unterschied zwischen C0 und C1.
Fehlender Test:
def test_editor_image():
assert check_access("editor", "image", False) == True
Übung 4: Exception-Behandlung
Code: divide.py
1
2
3
4
5
6
7
8
def safe_divide(a, b):
try:
result = a / b
except ZeroDivisionError:
return None
except TypeError:
return "invalid input"
return result
Testdatei
def test_normal_division():
assert safe_divide(10, 2) == 5.0
def test_divide_by_zero():
assert safe_divide(10, 0) is None
Coverage-Report
Name Stmts Miss Cover Missing
----------------------------------------------
src/divide.py 8 2 75% 7, 8
----------------------------------------------
Frage
Zeilen 7-8 sind der except TypeError-Block. Welche Eingabe würde diese Exception auslösen?
A) safe_divide("10", "2") - String-Division
B) safe_divide(10, -1) - negativer Divisor
C) safe_divide(0, 10) - Null-Dividend
D) safe_divide(10, 0.0) - Float-Null
Lösung anzeigen
Richtige Antwort: A
In Python wird TypeError ausgelöst, wenn du eine Operation mit inkompatiblen Typen durchführst.
safe_divide("10", "2"):"10" / "2"löst TypeError aus (kann Strings nicht dividieren)safe_divide(10, -1): Gibt -10.0 zurück (gültige Division)safe_divide(0, 10): Gibt 0.0 zurück (gültige Division)safe_divide(10, 0.0): Löst ZeroDivisionError aus (Division durch Float-Null)
Fehlende Tests:
def test_invalid_type():
assert safe_divide("hello", "world") == "invalid input"
def test_mixed_types():
assert safe_divide("10", 2) == "invalid input"
Übung 5: Schleifen-Coverage
Code: find_max.py
1
2
3
4
5
6
7
8
9
def find_max(numbers):
if not numbers:
return None
max_val = numbers[0]
for num in numbers[1:]:
if num > max_val:
max_val = num
return max_val
Testdatei
def test_single_element():
assert find_max([42]) == 42
def test_sorted_ascending():
assert find_max([1, 2, 3, 4, 5]) == 5
Coverage-Report
Name Stmts Miss Cover Missing
-----------------------------------------------
src/find_max.py 8 1 88% 3
-----------------------------------------------
Frage
Zeile 3 ist return None. Warum ist die Schleife (Zeilen 6-8) als abgedeckt angezeigt, aber Zeile 3 nicht?
A) Die Schleife wurde nie ausgeführt
B) Die if not numbers-Bedingung war nie True (kein Test mit leerer Liste)
C) Die num > max_val-Bedingung war nie True
D) Die Funktion wurde nie aufgerufen
Lösung anzeigen
Richtige Antwort: B
Analyse der Tests:
test_single_element([42]): Liste ist nicht leer, überspringe Zeile 3, betrete Schleife (aber Schleifenkörper läuft nicht, da[42][1:]leer ist)test_sorted_ascending([1,2,3,4,5]): Liste ist nicht leer, überspringe Zeile 3, Schleife läuft mehrmals
Zeile 3 erfordert, dass numbers falsy ist:
- Leere Liste:
[] - None (würde aber Fehler in Zeile 5 verursachen)
Hinweis: test_single_element([42]) deckt Zeilen 5-6 ab, aber der Schleifenkörper (Zeilen 7-8) könnte teilweise abgedeckt sein, weil numbers[1:] für eine Ein-Element-Liste leer ist.
Fehlender Test:
def test_empty_list():
assert find_max([]) is None
Bonus-Beobachtung: Um Zeile 8 (max_val = num) vollständig zu testen, brauchen wir eine Liste, in der ein Element größer als das erste ist:
def test_descending():
assert find_max([5, 4, 3, 2, 1]) == 5 # max_val wird nie aktualisiert
def test_max_at_end():
assert find_max([1, 2, 5]) == 5 # max_val wird mehrmals aktualisiert
Teil 2: Tests Entwerfen
In diesen Übungen entwirfst du Testfälle, die die fehlenden Zeilen abdecken würden. Schreibe den tatsächlichen Testfunktionscode.
Übung 6: Tests für validate_password Entwerfen
Code: validate_password.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def validate_password(password):
if len(password) < 8:
return "too short"
if len(password) > 128:
return "too long"
has_upper = any(c.isupper() for c in password)
has_lower = any(c.islower() for c in password)
has_digit = any(c.isdigit() for c in password)
if not has_upper:
return "needs uppercase"
if not has_lower:
return "needs lowercase"
if not has_digit:
return "needs digit"
return "valid"
Aktuelle Coverage
Name Stmts Miss Cover Missing
-------------------------------------------------------
src/validate_password.py 16 3 81% 6, 14, 16
-------------------------------------------------------
Aufgabe
Schreibe Testfunktionen, um die Zeilen 6, 14 und 16 abzudecken.
Selbst Ausprobieren
Verifiziere deinen Testentwurf durch Ausführen von pytest-cov! Erstelle diese Dateien:
src/validate_password.py:
def validate_password(password):
if len(password) < 8:
return "too short"
if len(password) > 128:
return "too long"
has_upper = any(c.isupper() for c in password)
has_lower = any(c.islower() for c in password)
has_digit = any(c.isdigit() for c in password)
if not has_upper:
return "needs uppercase"
if not has_lower:
return "needs lowercase"
if not has_digit:
return "needs digit"
return "valid"
tests/test_validate_password.py (Starter-Tests, die Zeilen 6, 14, 16 als fehlend lassen):
from src.validate_password import validate_password
def test_too_short():
assert validate_password("Short1") == "too short"
def test_needs_uppercase():
assert validate_password("lowercase1") == "needs uppercase"
def test_valid():
assert validate_password("ValidPass1") == "valid"
Führe Coverage aus, um fehlende Zeilen zu sehen:
uv run pytest tests/test_validate_password.py --cov=src --cov-report=term-missing
Füge jetzt deine Testfunktionen hinzu und führe erneut aus, um zu verifizieren, dass du die Zeilen 6, 14 und 16 abgedeckt hast!
Voraussetzungen: Python 3.12+, uv Package Manager, pytest und pytest-cov installiert (uv add pytest pytest-cov).
Lösung anzeigen
Zeile 6 (return "too long"): Benötigt Passwort > 128 Zeichen
def test_password_too_long():
long_password = "A" * 129 # 129 characters
assert validate_password(long_password) == "too long"
Zeile 14 (return "needs lowercase"): Benötigt Passwort mit Großbuchstaben und Ziffern, aber KEINEN Kleinbuchstaben
def test_password_needs_lowercase():
# Has uppercase (A) and digit (1), but no lowercase
assert validate_password("ALLCAPS12") == "needs lowercase"
Zeile 16 (return "needs digit"): Benötigt Passwort mit Groß- und Kleinbuchstaben, aber KEINE Ziffer
def test_password_needs_digit():
# Has uppercase and lowercase, but no digit
assert validate_password("NoDigitsHere") == "needs digit"
Vollständige Testsuite für 100% Coverage:
def test_too_short():
assert validate_password("Short1") == "too short"
def test_too_long():
assert validate_password("A" * 129) == "too long"
def test_needs_uppercase():
assert validate_password("lowercase1") == "needs uppercase"
def test_needs_lowercase():
assert validate_password("UPPERCASE1") == "needs lowercase"
def test_needs_digit():
assert validate_password("NoDigitsHere") == "needs digit"
def test_valid():
assert validate_password("ValidPass1") == "valid"
Übung 7: Tests für calculate_shipping Entwerfen
Code: shipping.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def calculate_shipping(weight, destination, express=False):
if weight <= 0:
raise ValueError("Weight must be positive")
if destination not in ["domestic", "international"]:
raise ValueError("Invalid destination")
base_rate = 5.0 if destination == "domestic" else 15.0
weight_charge = weight * 0.5
subtotal = base_rate + weight_charge
if express:
subtotal *= 1.5
if subtotal > 50:
discount = subtotal * 0.1
return subtotal - discount
return subtotal
Aktuelle Coverage
Name Stmts Miss Cover Missing
-----------------------------------------------
src/shipping.py 15 4 73% 3, 6, 15, 16
-----------------------------------------------
Aufgabe
Schreibe Testfunktionen, um die Zeilen 3, 6, 15 und 16 abzudecken.
Hinweis: Zeilen 15-16 sind innerhalb einer Bedingung if subtotal > 50.
Selbst Ausprobieren
Verifiziere deinen Testentwurf durch Ausführen von pytest-cov! Erstelle diese Dateien:
src/shipping.py:
def calculate_shipping(weight, destination, express=False):
if weight <= 0:
raise ValueError("Weight must be positive")
if destination not in ["domestic", "international"]:
raise ValueError("Invalid destination")
base_rate = 5.0 if destination == "domestic" else 15.0
weight_charge = weight * 0.5
subtotal = base_rate + weight_charge
if express:
subtotal *= 1.5
if subtotal > 50:
discount = subtotal * 0.1
return subtotal - discount
return subtotal
tests/test_shipping.py (Starter-Tests, die Zeilen 3, 6, 15, 16 als fehlend lassen):
from src.shipping import calculate_shipping
def test_domestic_shipping():
# weight=10, domestic: base=5, weight_charge=5, subtotal=10
result = calculate_shipping(10, "domestic")
assert result == 10.0
def test_international_shipping():
result = calculate_shipping(10, "international")
assert result == 20.0 # 15 + 5
def test_express_shipping():
result = calculate_shipping(10, "domestic", express=True)
assert result == 15.0 # (5 + 5) * 1.5
Führe Coverage aus, um fehlende Zeilen zu sehen:
uv run pytest tests/test_shipping.py --cov=src --cov-report=term-missing
Füge jetzt deine Testfunktionen hinzu (einschließlich Tests mit pytest.raises) und führe erneut aus, um zu verifizieren, dass du die Zeilen 3, 6, 15 und 16 abgedeckt hast!
Voraussetzungen: Python 3.12+, uv Package Manager, pytest und pytest-cov installiert (uv add pytest pytest-cov).
Lösung anzeigen
Zeile 3 (raise ValueError("Weight must be positive")):
import pytest
def test_invalid_weight():
with pytest.raises(ValueError, match="Weight must be positive"):
calculate_shipping(0, "domestic")
def test_negative_weight():
with pytest.raises(ValueError, match="Weight must be positive"):
calculate_shipping(-5, "domestic")
Zeile 6 (raise ValueError("Invalid destination")):
def test_invalid_destination():
with pytest.raises(ValueError, match="Invalid destination"):
calculate_shipping(10, "mars")
Zeilen 15-16 (Rabatt für subtotal > 50):
Wir brauchen subtotal > 50. Berechnen wir:
- domestic: base_rate = 5.0
- weight_charge = weight * 0.5
- subtotal = 5.0 + weight * 0.5 > 50
- weight * 0.5 > 45
- weight > 90
def test_discount_applied():
# weight=100, domestic: base=5, weight_charge=50, subtotal=55
# 55 > 50, so discount = 5.5, result = 49.5
result = calculate_shipping(100, "domestic")
assert result == 49.5 # 55 - 5.5 = 49.5
Oder mit international + express für größeres Subtotal:
def test_discount_with_express():
# weight=50, international, express
# base=15, weight_charge=25, subtotal=40 * 1.5 = 60
# 60 > 50, discount = 6, result = 54
result = calculate_shipping(50, "international", express=True)
assert result == 54 # 60 - 6 = 54
Übung 8: Tests für parse_config Entwerfen
Code: config.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def parse_config(config_string):
if not config_string:
return {}
result = {}
lines = config_string.strip().split('\n')
for line in lines:
line = line.strip()
if not line or line.startswith('#'):
continue
if '=' not in line:
raise ValueError(f"Invalid config line: {line}")
key, value = line.split('=', 1)
key = key.strip()
value = value.strip()
if not key:
raise ValueError("Empty key not allowed")
result[key] = value
return result
Aktuelle Coverage
Name Stmts Miss Cover Missing
---------------------------------------------
src/config.py 18 4 78% 3, 12, 15, 21
---------------------------------------------
Aufgabe
Schreibe Testfunktionen, um die Zeilen 3, 12, 15 und 21 abzudecken.
Selbst Ausprobieren
Verifiziere deinen Testentwurf durch Ausführen von pytest-cov! Erstelle diese Dateien:
src/config.py:
def parse_config(config_string):
if not config_string:
return {}
result = {}
lines = config_string.strip().split('\n')
for line in lines:
line = line.strip()
if not line or line.startswith('#'):
continue
if '=' not in line:
raise ValueError(f"Invalid config line: {line}")
key, value = line.split('=', 1)
key = key.strip()
value = value.strip()
if not key:
raise ValueError("Empty key not allowed")
result[key] = value
return result
tests/test_config.py (Starter-Tests, die Zeilen 3, 12, 15, 21 als fehlend lassen):
from src.config import parse_config
def test_simple_config():
config = "key1=value1\nkey2=value2"
result = parse_config(config)
assert result == {"key1": "value1", "key2": "value2"}
def test_config_with_spaces():
config = " key = value "
result = parse_config(config)
assert result == {"key": "value"}
Führe Coverage aus, um fehlende Zeilen zu sehen:
uv run pytest tests/test_config.py --cov=src --cov-report=term-missing
Füge jetzt deine Testfunktionen hinzu, um leere Eingabe, Kommentare, ungültige Zeilen und leere Schlüssel abzudecken!
Voraussetzungen: Python 3.12+, uv Package Manager, pytest und pytest-cov installiert (uv add pytest pytest-cov).
Lösung anzeigen
Zeile 3 (return {}): Leerer/falscher config_string
def test_empty_string():
assert parse_config("") == {}
def test_none_input():
assert parse_config(None) == {}
Zeile 12 (continue): Zeile ist leer oder beginnt mit ‘#’ (Kommentar)
def test_with_comments():
config = """# This is a comment
key1=value1
# Another comment
key2=value2
"""
result = parse_config(config)
assert result == {"key1": "value1", "key2": "value2"}
def test_empty_lines():
config = """key1=value1
key2=value2
"""
result = parse_config(config)
assert result == {"key1": "value1", "key2": "value2"}
Zeile 15 (raise ValueError("Invalid config line")): Zeile ohne ‘=’
def test_invalid_line_no_equals():
import pytest
with pytest.raises(ValueError, match="Invalid config line"):
parse_config("this is not a valid line")
Zeile 21 (raise ValueError("Empty key not allowed")): Schlüssel ist nach dem Trimmen leer
def test_empty_key():
import pytest
with pytest.raises(ValueError, match="Empty key not allowed"):
parse_config(" =some_value")
Übung 9: Tests für calculate_discount Entwerfen (Komplex)
Code: discount.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def calculate_discount(amount, is_member, coupon_code):
if amount <= 0:
return 0
discount = 0.0
if is_member:
discount += 0.10 # 10% member discount
if coupon_code is not None:
if coupon_code == "SAVE20":
discount += 0.20
elif coupon_code == "SAVE10":
discount += 0.10
elif coupon_code.startswith("VIP"):
if is_member:
discount += 0.30
else:
discount += 0.15
# Cap discount at 50%
if discount > 0.50:
discount = 0.50
return amount * discount
Aktuelle Coverage
Name Stmts Miss Cover Missing
-----------------------------------------------
src/discount.py 20 5 75% 3, 14, 17, 18, 22
-----------------------------------------------
Aufgabe
Entwirf eine minimale Testsuite (5-6 Tests), um 100% Coverage zu erreichen.
Selbst Ausprobieren
Verifiziere deinen Testentwurf durch Ausführen von pytest-cov! Erstelle diese Dateien:
src/discount.py:
def calculate_discount(amount, is_member, coupon_code):
if amount <= 0:
return 0
discount = 0.0
if is_member:
discount += 0.10 # 10% member discount
if coupon_code is not None:
if coupon_code == "SAVE20":
discount += 0.20
elif coupon_code == "SAVE10":
discount += 0.10
elif coupon_code.startswith("VIP"):
if is_member:
discount += 0.30
else:
discount += 0.15
# Cap discount at 50%
if discount > 0.50:
discount = 0.50
return amount * discount
tests/test_discount.py (Starter-Tests, die Zeilen 3, 14, 17, 18, 22 als fehlend lassen):
from src.discount import calculate_discount
def test_member_save20():
# 10% member + 20% coupon = 30% of 100 = 30
assert calculate_discount(100, True, "SAVE20") == 30
def test_nonmember_save20():
# 20% coupon only = 20% of 100 = 20
assert calculate_discount(100, False, "SAVE20") == 20
Führe Coverage aus, um fehlende Zeilen zu sehen:
uv run pytest tests/test_discount.py --cov=src --cov-report=term-missing
Füge Tests hinzu, um alle fehlenden Zeilen abzudecken. Bonus-Herausforderung: Kannst du Zeile 22 (das 50%-Cap) erreichen? Denke über den maximal möglichen Rabatt mit der aktuellen Code-Logik nach!
Voraussetzungen: Python 3.12+, uv Package Manager, pytest und pytest-cov installiert (uv add pytest pytest-cov).
Lösung anzeigen
Ordnen wir fehlende Zeilen den Bedingungen zu:
- Zeile 3 (
return 0): amount <= 0 - Zeile 14 (
discount += 0.10): coupon_code == “SAVE10” - Zeile 17 (
discount += 0.30): Coupon beginnt mit “VIP” UND is_member - Zeile 18 (
discount += 0.15): Coupon beginnt mit “VIP” UND NICHT is_member - Zeile 22 (
discount = 0.50): discount > 0.50
Minimale Testsuite:
def test_zero_amount():
# Covers line 3
assert calculate_discount(0, True, "SAVE20") == 0
def test_member_with_save10():
# Covers line 14 (SAVE10 coupon)
# 10% member + 10% coupon = 20% of 100 = 20
assert calculate_discount(100, True, "SAVE10") == 20
def test_member_with_vip():
# Covers line 17 (VIP + member)
# 10% member + 30% VIP = 40% of 100 = 40
assert calculate_discount(100, True, "VIP123") == 40
def test_nonmember_with_vip():
# Covers line 18 (VIP + non-member)
# 0% member + 15% VIP = 15% of 100 = 15
assert calculate_discount(100, False, "VIP999") == 15
def test_discount_cap():
# Covers line 22 (discount > 50%)
# Member (10%) + SAVE20 (20%) + ... need more!
# Actually: Member + VIP = 10% + 30% = 40%, not > 50%
# Need: Member (10%) + SAVE20 (20%) = 30%, still < 50%
# The code can't actually reach > 50% with current logic!
# Maximum: Member (10%) + VIP (30%) = 40%
pass # See note below
Moment! Wenn wir den Code betrachten, ist der maximale Rabatt:
- Member: 10%
- Bester Coupon: VIP für Member = 30%
- Gesamtmaximum: 40%
Zeile 22 ist mit dem aktuellen Code tatsächlich unerreichbar! Das ist toter Code.
Wenn wir annehmen, dass der Code Stapeln erlauben sollte:
def test_discount_cap():
# This would require modifying the code to allow stacking
# OR this is dead code that should be removed
pass
Praktische Testsuite (für erreichbaren Code):
def test_zero_amount():
assert calculate_discount(0, True, "SAVE20") == 0
def test_member_no_coupon():
assert calculate_discount(100, True, None) == 10
def test_nonmember_save20():
assert calculate_discount(100, False, "SAVE20") == 20
def test_member_save10():
assert calculate_discount(100, True, "SAVE10") == 20
def test_member_vip():
assert calculate_discount(100, True, "VIP123") == 40
def test_nonmember_vip():
assert calculate_discount(100, False, "VIP999") == 15
Wichtige Erkenntnis: Coverage-Analyse kann toten Code aufdecken (Zeile 22 ist unerreichbar)!
Übung 10: Vollständige Coverage-Herausforderung
Code: process_order.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def process_order(items, customer_type, promo_code=None):
if not items:
return {"status": "error", "message": "No items"}
subtotal = sum(item["price"] * item["quantity"] for item in items)
if subtotal <= 0:
return {"status": "error", "message": "Invalid total"}
discount_rate = 0
if customer_type == "premium":
discount_rate = 0.15
elif customer_type == "member":
discount_rate = 0.05
if promo_code == "FLASH50" and subtotal >= 100:
discount_rate += 0.50
elif promo_code == "FLASH25" and subtotal >= 50:
discount_rate += 0.25
if discount_rate > 0.60:
discount_rate = 0.60
discount = subtotal * discount_rate
total = subtotal - discount
return {
"status": "success",
"subtotal": subtotal,
"discount": discount,
"total": total
}
Aufgabe
Entwirf eine vollständige Testsuite, die 100% Branch Coverage (C1) erreicht.
Liste alle Tests auf, die du schreiben würdest, mit dem erwarteten Verhalten.
Selbst Ausprobieren
Das ist die ultimative Herausforderung! Verifiziere deinen Testentwurf durch Ausführen von pytest-cov mit Branch Coverage:
src/process_order.py:
def process_order(items, customer_type, promo_code=None):
if not items:
return {"status": "error", "message": "No items"}
subtotal = sum(item["price"] * item["quantity"] for item in items)
if subtotal <= 0:
return {"status": "error", "message": "Invalid total"}
discount_rate = 0
if customer_type == "premium":
discount_rate = 0.15
elif customer_type == "member":
discount_rate = 0.05
if promo_code == "FLASH50" and subtotal >= 100:
discount_rate += 0.50
elif promo_code == "FLASH25" and subtotal >= 50:
discount_rate += 0.25
if discount_rate > 0.60:
discount_rate = 0.60
discount = subtotal * discount_rate
total = subtotal - discount
return {
"status": "success",
"subtotal": subtotal,
"discount": discount,
"total": total
}
tests/test_process_order.py (erstelle eine leere Datei und baue deine Testsuite auf):
from src.process_order import process_order
# Add your tests here to achieve 100% C1 coverage
Führe Coverage mit Branch-Analyse aus:
uv run pytest tests/test_process_order.py --cov=src --cov-branch --cov-report=term-missing
Ziel: Erreiche 100% in der “Cover”-Spalte UND habe 0 in der “BrPart” (partielle Branches)-Spalte.
Hinweis: Du musst testen:
- Leere Items-Liste
- Null/negative Summe
- Jeden Kundentyp (premium, member, regular)
- Jeden Promo-Code-Pfad mit verschiedenen Subtotals
- Das Rabatt-Cap (premium + FLASH50 = 65% > 60%)
Voraussetzungen: Python 3.12+, uv Package Manager, pytest und pytest-cov installiert (uv add pytest pytest-cov).
Lösung anzeigen
Branch-Analyse
| Zeile | Bedingung | Abzudeckende Branches |
|---|---|---|
| 2 | not items |
True, False |
| 7 | subtotal <= 0 |
True, False |
| 12 | customer_type == "premium" |
True, False |
| 14 | customer_type == "member" |
True, False (wenn nicht premium) |
| 17 | promo_code == "FLASH50" and subtotal >= 100 |
True, False |
| 19 | promo_code == "FLASH25" and subtotal >= 50 |
True, False |
| 22 | discount_rate > 0.60 |
True, False |
Vollständige Testsuite
import pytest
# Test 1: Empty items (line 2 True)
def test_empty_items():
result = process_order([], "member")
assert result == {"status": "error", "message": "No items"}
# Test 2: Invalid total (line 7 True)
def test_invalid_total():
items = [{"price": 0, "quantity": 5}]
result = process_order(items, "member")
assert result == {"status": "error", "message": "Invalid total"}
# Test 3: Premium customer (line 12 True)
def test_premium_customer():
items = [{"price": 100, "quantity": 1}]
result = process_order(items, "premium")
assert result["discount"] == 15 # 15% of 100
# Test 4: Member customer (line 14 True)
def test_member_customer():
items = [{"price": 100, "quantity": 1}]
result = process_order(items, "member")
assert result["discount"] == 5 # 5% of 100
# Test 5: Regular customer (lines 12, 14 both False)
def test_regular_customer():
items = [{"price": 100, "quantity": 1}]
result = process_order(items, "regular")
assert result["discount"] == 0
# Test 6: FLASH50 promo with high subtotal (line 17 True)
def test_flash50_promo():
items = [{"price": 100, "quantity": 1}]
result = process_order(items, "regular", "FLASH50")
assert result["discount"] == 50 # 50% of 100
# Test 7: FLASH50 with low subtotal (line 17 False due to subtotal < 100)
def test_flash50_low_subtotal():
items = [{"price": 50, "quantity": 1}]
result = process_order(items, "regular", "FLASH50")
assert result["discount"] == 0 # Promo doesn't apply
# Test 8: FLASH25 promo (line 19 True)
def test_flash25_promo():
items = [{"price": 75, "quantity": 1}]
result = process_order(items, "regular", "FLASH25")
assert result["discount"] == 18.75 # 25% of 75
# Test 9: Discount cap (line 22 True)
def test_discount_cap():
# Premium (15%) + FLASH50 (50%) = 65% > 60%
items = [{"price": 100, "quantity": 1}]
result = process_order(items, "premium", "FLASH50")
assert result["discount"] == 60 # Capped at 60%
assert result["total"] == 40
# Test 10: No discount cap needed (line 22 False)
def test_no_cap_needed():
# Member (5%) + FLASH25 (25%) = 30% < 60%
items = [{"price": 100, "quantity": 1}]
result = process_order(items, "member", "FLASH25")
assert result["discount"] == 30
assert result["total"] == 70
Erreichte Coverage: 100% C1
Jedes Branch-Ergebnis ist durch mindestens einen Test abgedeckt.
Zusammenfassung
Was Du Gelernt Hast
In Teil 1 hast du geübt:
- pytest-cov-Ausgabe zu lesen
- “Missing”-Zeilen auf ungetestete Bedingungen abzubilden
- Zu verstehen, warum spezifische Branches nicht ausgeführt wurden
In Teil 2 hast du geübt:
- Testeingaben zu entwerfen, die spezifische Code-Pfade auslösen
- Testfunktionen mit korrekten Assertions zu schreiben
- Vollständige Branch Coverage zu erreichen
Schlüsselstrategien
- Zeilen auf Bedingungen abbilden: Betrachte den Code und verstehe, welche Bedingung zu jeder Zeile führt
- Rückwärts arbeiten: “Welche Eingabe würde diese Bedingung True/False machen?”
- Grenzfälle berücksichtigen: Leere Eingaben, Grenzwerte, Fehlerbedingungen
- Nach totem Code suchen: Coverage kann unerreichbaren Code aufdecken
- Berechnungen verifizieren: Führe Code nicht nur aus; prüfe korrekte Ergebnisse per Assert
Häufige Muster
| Fehlender Zeilentyp | Typische Ursache | Lösung |
|---|---|---|
| Frühes Return | Guard Clause nicht ausgelöst | Mit ungültiger Eingabe testen |
| Exception Raise | Fehlerbedingung nicht getestet | pytest.raises() verwenden |
| Else-Branch | Immer True-Branch genommen | Mit False-Bedingung testen |
| Schleifenkörper | Leere Eingabe oder früher Abbruch | Mit gültigen Daten testen |
| Verschachteltes If | Äußere Bedingung nie True | Zuerst äußere Bedingung erfüllen |
Was Kommt Als Nächstes?
Wende diese Fähigkeiten in der GitHub Classroom Aufgabe an, wo du die Coverage an einer echten Codebase mit sofortigem CI-Feedback verbessern wirst.
GitHub Classroom Aufgabe
Wende diese Fähigkeiten jetzt in einer echten praktischen Aufgabe mit sofortigem CI-Feedback an!
Coverage Improvement Übung akzeptieren
In dieser Aufgabe wirst du:
- Ein Repository mit ~54% Coverage klonen
uv run pytest --cov=src --cov-report=term-missingausführen- Fehlende Zeilen in zwei Modulen identifizieren (
discount.pyundsales.py) - Tests hinzufügen, um 85%+ Coverage zu erreichen
- Pushen und verifizieren, dass CI besteht (grüner Haken = Erfolg!)
Die Fähigkeiten aus dieser Übung gelten direkt für diese Herausforderung!
Voraussetzungen:
- Python 3.12+ installiert
- uv Package Manager installiert
- Git konfiguriert und GitHub-Zugang
Viel Erfolg!