Home

03 Testing Fundamentals: Coverage Detective Exercise

exercise coverage pytest-cov testing analysis design chapter-03

New: Interactive Exercise Available!

Want to track your progress and get instant feedback? Try our new interactive version with progress tracking, immediate explanations, and score calculation.

Start Interactive Exercise →

Coverage Detective Exercise: Analyzing Reports and Designing Tests

Introduction

In this exercise, you’ll become a Coverage Detective. You’ll analyze real pytest-cov output, identify which code paths are untested, and design tests to cover the gaps.


Preparation: Read First

Before attempting this exercise, study the following lecture sections:

From Chapter 03 (TDD and CI): TDD and CI:

From Chapter 03 (Testing Theory and Coverage): Testing Theory & Coverage:

Focus areas: Interpreting “Missing” lines, mapping lines to conditions, designing targeted tests


Learning Objectives:

Structure:

Time: 45 minutes total (20 min Part 1, 25 min Part 2)


Part 1: Analysis

In these exercises, you’ll analyze coverage reports and identify the root cause of missing coverage.


Exercise 1: Simple Missing 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"

Test File: 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
--------------------------------------------------

Question

What conditions are NOT covered, and why?

A) age < 0 and age >= 65, because no test uses negative or senior ages
B) age < 18 and age < 65, because those conditions always fail
C) age < 0 and age < 18, because no test uses ages below 18
D) The report is wrong; all conditions should be covered

Show Answer

Correct Answer: A

Let’s trace what’s executed:

  • test_minor(15): age < 0 is False (skip line 3), age < 18 is True (return “minor”)
  • test_adult(30): age < 0 is False, age < 18 is False, age < 65 is True (return “adult”)

Lines NOT executed:

  • Line 3 (return "invalid: negative"): Needs age < 0, e.g., validate_age(-5)
  • Line 8 (return "senior"): Needs age >= 65, e.g., validate_age(70)

Missing tests:

def test_negative_age():
    assert validate_age(-5) == "invalid: negative"

def test_senior():
    assert validate_age(70) == "senior"

Exercise 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}"

Test File

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
-------------------------------------------------

Question

Line 3 is return "Invalid name". Why is it NOT covered?

A) The if formal condition is never True
B) The not first or not last condition is never True
C) The formal parameter is always False
D) The function has a syntax error

Show Answer

Correct Answer: B

Let’s analyze:

  • 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 → Skip line 3
  • test_formal_name("Jane", "Smith", True): same logic, line 3 is skipped

Line 3 requires:

  • first is empty/None/falsy, OR
  • last is empty/None/falsy

Missing 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"

Exercise 3: Multiple Conditions in One Line

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

Test File

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
--------------------------------------------------

Question

Line 12 is if user_role == "editor" and resource_type in ["document", "image"]:. Why is part of this line marked as partially covered?

A) “editor” role was never tested
B) “document” resource was never tested
C) The “image” resource type was never tested (only “document” was)
D) The is_owner parameter wasn’t tested

Show Answer

Correct Answer: C

Looking at the tests:

  • test_editor_document: user_role=”editor”, resource_type=”document” → True
  • The condition resource_type in ["document", "image"] is True for “document”

But “image” was never tested!

The list ["document", "image"] has two valid values:

  • “document”: Covered by test_editor_document
  • “image”: NOT covered

Note: Some coverage tools report this as line 12 “partially covered” or show it in branch coverage, while statement coverage might show 100%. This is the difference between C0 and C1.

Missing test:

def test_editor_image():
    assert check_access("editor", "image", False) == True

Exercise 4: Exception Handling

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

Test File

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
----------------------------------------------

Question

Lines 7-8 are the except TypeError block. What input would trigger this exception?

A) safe_divide("10", "2") - string division
B) safe_divide(10, -1) - negative divisor
C) safe_divide(0, 10) - zero dividend
D) safe_divide(10, 0.0) - float zero

Show Answer

Correct Answer: A

In Python, TypeError is raised when you try to perform an operation on incompatible types.

  • safe_divide("10", "2"): "10" / "2" raises TypeError (can’t divide strings)
  • safe_divide(10, -1): Returns -10.0 (valid division)
  • safe_divide(0, 10): Returns 0.0 (valid division)
  • safe_divide(10, 0.0): Raises ZeroDivisionError (division by float zero)

Missing test:

def test_invalid_type():
    assert safe_divide("hello", "world") == "invalid input"

def test_mixed_types():
    assert safe_divide("10", 2) == "invalid input"

Exercise 5: Loop 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

Test File

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
-----------------------------------------------

Question

Line 3 is return None. Why is the loop (lines 6-8) shown as covered but line 3 is not?

A) The loop was never executed
B) The if not numbers condition was never True (no empty list test)
C) The num > max_val condition was never True
D) The function was never called

Show Answer

Correct Answer: B

Analyzing the tests:

  • test_single_element([42]): List is not empty, skip line 3, enter loop (but loop body doesn’t run since [42][1:] is empty)
  • test_sorted_ascending([1,2,3,4,5]): List is not empty, skip line 3, loop runs multiple times

Line 3 requires numbers to be falsy:

  • Empty list: []
  • None (but would cause error on line 5)

Note: test_single_element([42]) covers line 5-6 but the loop body (lines 7-8) might be partially covered because numbers[1:] is empty for a single-element list.

Missing test:

def test_empty_list():
    assert find_max([]) is None

Bonus observation: To fully test line 8 (max_val = num), we need a list where some element is greater than the first:

def test_descending():
    assert find_max([5, 4, 3, 2, 1]) == 5  # max_val never updated

def test_max_at_end():
    assert find_max([1, 2, 5]) == 5  # max_val updated multiple times

Part 2: Design Tests

In these exercises, you’ll design test cases that would cover the missing lines. Write the actual test function code.


Exercise 6: Design Tests for validate_password

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"

Current Coverage

Name                      Stmts   Miss  Cover   Missing
-------------------------------------------------------
src/validate_password.py     16      3    81%   6, 14, 16
-------------------------------------------------------

Task

Write test functions to cover lines 6, 14, and 16.

Try It Yourself

Verify your test design by running pytest-cov! Create these files:

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 that leave lines 6, 14, 16 missing):

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"

Run coverage to see missing lines:

uv run pytest tests/test_validate_password.py --cov=src --cov-report=term-missing

Now add your test functions and run again to verify you’ve covered lines 6, 14, and 16!

Prerequisites: Python 3.12+, uv package manager, pytest and pytest-cov installed (uv add pytest pytest-cov).

Show Solution

Line 6 (return "too long"): Needs password > 128 characters

def test_password_too_long():
    long_password = "A" * 129  # 129 characters
    assert validate_password(long_password) == "too long"

Line 14 (return "needs lowercase"): Needs password with uppercase and digits, but NO lowercase

def test_password_needs_lowercase():
    # Has uppercase (A) and digit (1), but no lowercase
    assert validate_password("ALLCAPS12") == "needs lowercase"

Line 16 (return "needs digit"): Needs password with upper and lower, but NO digit

def test_password_needs_digit():
    # Has uppercase and lowercase, but no digit
    assert validate_password("NoDigitsHere") == "needs digit"

Complete test suite for 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"

Exercise 7: Design Tests for calculate_shipping

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

Current Coverage

Name              Stmts   Miss  Cover   Missing
-----------------------------------------------
src/shipping.py      15      4    73%   3, 6, 15, 16
-----------------------------------------------

Task

Write test functions to cover lines 3, 6, 15, and 16.

Hint: Lines 15-16 are inside a condition if subtotal > 50.

Try It Yourself

Verify your test design by running pytest-cov! Create these files:

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 that leave lines 3, 6, 15, 16 missing):

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

Run coverage to see missing lines:

uv run pytest tests/test_shipping.py --cov=src --cov-report=term-missing

Now add your test functions (including tests that use pytest.raises) and run again to verify you’ve covered lines 3, 6, 15, and 16!

Prerequisites: Python 3.12+, uv package manager, pytest and pytest-cov installed (uv add pytest pytest-cov).

Show Solution

Line 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")

Line 6 (raise ValueError("Invalid destination")):

def test_invalid_destination():
    with pytest.raises(ValueError, match="Invalid destination"):
        calculate_shipping(10, "mars")

Lines 15-16 (discount for subtotal > 50):

We need subtotal > 50. Let’s calculate:

  • 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

Or with international + express for larger 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

Exercise 8: Design Tests for parse_config

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

Current Coverage

Name            Stmts   Miss  Cover   Missing
---------------------------------------------
src/config.py      18      4    78%   3, 12, 15, 21
---------------------------------------------

Task

Write test functions to cover lines 3, 12, 15, and 21.

Try It Yourself

Verify your test design by running pytest-cov! Create these files:

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 that leave lines 3, 12, 15, 21 missing):

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"}

Run coverage to see missing lines:

uv run pytest tests/test_config.py --cov=src --cov-report=term-missing

Now add your test functions to cover empty input, comments, invalid lines, and empty keys!

Prerequisites: Python 3.12+, uv package manager, pytest and pytest-cov installed (uv add pytest pytest-cov).

Show Solution

Line 3 (return {}): Empty/falsy config_string

def test_empty_string():
    assert parse_config("") == {}

def test_none_input():
    assert parse_config(None) == {}

Line 12 (continue): Line is empty or starts with ‘#’ (comment)

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"}

Line 15 (raise ValueError("Invalid config line")): Line without ‘=’

def test_invalid_line_no_equals():
    import pytest
    with pytest.raises(ValueError, match="Invalid config line"):
        parse_config("this is not a valid line")

Line 21 (raise ValueError("Empty key not allowed")): Key is empty after stripping

def test_empty_key():
    import pytest
    with pytest.raises(ValueError, match="Empty key not allowed"):
        parse_config("  =some_value")

Exercise 9: Design Tests for calculate_discount (Complex)

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

Current Coverage

Name              Stmts   Miss  Cover   Missing
-----------------------------------------------
src/discount.py      20      5    75%   3, 14, 17, 18, 22
-----------------------------------------------

Task

Design a minimal test suite (5-6 tests) to achieve 100% coverage.

Try It Yourself

Verify your test design by running pytest-cov! Create these files:

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 that leave lines 3, 14, 17, 18, 22 missing):

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

Run coverage to see missing lines:

uv run pytest tests/test_discount.py --cov=src --cov-report=term-missing

Add tests to cover all the missing lines. Bonus challenge: Can you reach line 22 (the 50% cap)? Think about the maximum possible discount with the current code logic!

Prerequisites: Python 3.12+, uv package manager, pytest and pytest-cov installed (uv add pytest pytest-cov).

Show Solution

Let’s map missing lines to conditions:

  • Line 3 (return 0): amount <= 0
  • Line 14 (discount += 0.10): coupon_code == “SAVE10”
  • Line 17 (discount += 0.30): coupon starts with “VIP” AND is_member
  • Line 18 (discount += 0.15): coupon starts with “VIP” AND NOT is_member
  • Line 22 (discount = 0.50): discount > 0.50

Minimal test suite:

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

Wait! Looking at the code, the maximum discount is:

  • Member: 10%
  • Best coupon: VIP for member = 30%
  • Total max: 40%

Line 22 is actually unreachable with the current code! This is dead code.

If we assume the code should allow stacking:

def test_discount_cap():
    # This would require modifying the code to allow stacking
    # OR this is dead code that should be removed
    pass

Practical test suite (covering reachable 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

Key insight: Coverage analysis can reveal dead code (line 22 is unreachable)!


Exercise 10: Full Coverage Challenge

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
    }

Task

Design a complete test suite that achieves 100% branch coverage (C1).

List all the tests you would write, with the expected behavior.

Try It Yourself

This is the ultimate challenge! Verify your test design by running pytest-cov with 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 (create an empty file and build your test suite):

from src.process_order import process_order

# Add your tests here to achieve 100% C1 coverage

Run coverage with branch analysis:

uv run pytest tests/test_process_order.py --cov=src --cov-branch --cov-report=term-missing

Goal: Achieve 100% in both the “Cover” column AND have 0 in the “BrPart” (partial branches) column.

Hint: You need to test:

  • Empty items list
  • Zero/negative total
  • Each customer type (premium, member, regular)
  • Each promo code path with different subtotals
  • The discount cap (premium + FLASH50 = 65% > 60%)

Prerequisites: Python 3.12+, uv package manager, pytest and pytest-cov installed (uv add pytest pytest-cov).

Show Solution

Branch Analysis

Line Condition Branches to Cover
2 not items True, False
7 subtotal <= 0 True, False
12 customer_type == "premium" True, False
14 customer_type == "member" True, False (when not 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

Complete Test Suite

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

Coverage achieved: 100% C1

Each branch outcome is covered by at least one test.


Summary

What You’ve Learned

In Part 1, you practiced:

In Part 2, you practiced:

Key Strategies

  1. Map lines to conditions: Look at the code and understand what condition leads to each line
  2. Work backwards: “What input would make this condition True/False?”
  3. Consider edge cases: Empty inputs, boundary values, error conditions
  4. Check for dead code: Coverage can reveal unreachable code
  5. Verify calculations: Don’t just execute code; assert correct results

Common Patterns

Missing Line Type Typical Cause Solution
Early return Guard clause not triggered Test with invalid input
Exception raise Error condition not tested Use pytest.raises()
Else branch Always take True branch Test with False condition
Loop body Empty input or early exit Test with valid data
Nested if Outer condition never True Satisfy outer condition first

What’s Next?

Apply these skills in the GitHub Classroom Assignment where you’ll improve coverage on a real codebase with immediate CI feedback.


GitHub Classroom Assignment

Now apply these skills in a real hands-on assignment with immediate CI feedback!

Accept the Coverage Improvement Exercise

In this assignment, you’ll:

  1. Clone a repository with ~54% coverage
  2. Run uv run pytest --cov=src --cov-report=term-missing
  3. Identify missing lines in two modules (discount.py and sales.py)
  4. Add tests to reach 85%+ coverage
  5. Push and verify CI passes (green check = success!)

The skills from this exercise directly apply to that challenge!

Prerequisites:

Good luck!

© 2026 Dominik Mueller   •  Powered by Soopr   •  Theme  Moonwalk