Home

03 Grundlagen des Testens: Coverage Detektiv-Übung

exercise coverage pytest-cov testing analysis design chapter-03

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.

Interaktive Übung starten →

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:

Aus Kapitel 03 (Testtheorie und Coverage): Testtheorie & Coverage:

Fokusgebiete: “Missing”-Zeilen interpretieren, Zeilen auf Bedingungen abbilden, gezielte Tests entwerfen


Lernziele:

Struktur:

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:

  • first ist leer/None/falsy, ODER
  • last ist 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:

In Teil 2 hast du geübt:

Schlüsselstrategien

  1. Zeilen auf Bedingungen abbilden: Betrachte den Code und verstehe, welche Bedingung zu jeder Zeile führt
  2. Rückwärts arbeiten: “Welche Eingabe würde diese Bedingung True/False machen?”
  3. Grenzfälle berücksichtigen: Leere Eingaben, Grenzwerte, Fehlerbedingungen
  4. Nach totem Code suchen: Coverage kann unerreichbaren Code aufdecken
  5. 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:

  1. Ein Repository mit ~54% Coverage klonen
  2. uv run pytest --cov=src --cov-report=term-missing ausführen
  3. Fehlende Zeilen in zwei Modulen identifizieren (discount.py und sales.py)
  4. Tests hinzufügen, um 85%+ Coverage zu erreichen
  5. Pushen und verifizieren, dass CI besteht (grüner Haken = Erfolg!)

Die Fähigkeiten aus dieser Übung gelten direkt für diese Herausforderung!

Voraussetzungen:

Viel Erfolg!

© 2026 Dominik Mueller   •  Powered by Soopr   •  Theme  Moonwalk