03 Testing Fundamentals: Coverage Detective Exercise
December 2025 (7361 Words, 41 Minutes)
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.
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:
- Part 3: Setting Up Coverage Reporting - Understanding
--cov-report=term-missing
From Chapter 03 (Testing Theory and Coverage): Testing Theory & Coverage:
- Section 5: Systematic Test Design - Designing tests for specific branches
Focus areas: Interpreting “Missing” lines, mapping lines to conditions, designing targeted tests
Learning Objectives:
- Interpret pytest-cov output and identify missing lines
- Understand why specific lines weren’t executed
- Design test cases that cover specific branches
- Write test functions that achieve target coverage
Structure:
- Part 1 (Exercises 1-5): Analysis - Identify what’s missing and why
- Part 2 (Exercises 6-10): Design - Create tests to cover the gaps
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:
firstis empty/None/falsy, ORlastis 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:
- Reading pytest-cov output
- Mapping “Missing” lines to untested conditions
- Understanding why specific branches weren’t executed
In Part 2, you practiced:
- Designing test inputs to trigger specific code paths
- Writing test functions with proper assertions
- Achieving complete branch coverage
Key Strategies
- Map lines to conditions: Look at the code and understand what condition leads to each line
- Work backwards: “What input would make this condition True/False?”
- Consider edge cases: Empty inputs, boundary values, error conditions
- Check for dead code: Coverage can reveal unreachable code
- 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:
- Clone a repository with ~54% coverage
- Run
uv run pytest --cov=src --cov-report=term-missing - Identify missing lines in two modules (
discount.pyandsales.py) - Add tests to reach 85%+ coverage
- Push and verify CI passes (green check = success!)
The skills from this exercise directly apply to that challenge!
Prerequisites:
- Python 3.12+ installed
- uv package manager installed
- Git configured and GitHub access
Good luck!