03 Exercise: Coverage Detective
Instructions
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
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 (Questions 1-5): Analysis - Identify what’s missing and why
- Part 2 (Questions 6-10): Design - Create tests to cover the gaps
Scoring Guide
- 9-10 correct: Excellent! Ready for the GitHub Classroom assignment
- 7-8 correct: Good understanding! Review the explanations
- 5-6 correct: Fair understanding. Revisit the lecture materials
- Below 5: Please review the coverage concepts before continuing
GitHub Classroom Assignment
After completing this quiz, apply your skills in a real hands-on assignment:
Accept the Coverage Improvement Exercise
You’ll improve coverage from ~54% to 85%+ with immediate CI feedback!
Code: validate_age.py
1: def validate_age(age):
2: if age < 0:
3: return "invalid: negative"
4: if age < 18:
5: return "minor"
6: if age < 65:
7: return "adult"
8: return "senior"
Tests:
def test_minor():
assert validate_age(15) == "minor"
def test_adult():
assert validate_age(30) == "adult"
Coverage Report: Missing: 3, 8
What conditions are NOT covered, and why?
Code: format_name.py
1: def format_name(first, last, formal=False):
2: if not first or not last:
3: return "Invalid name"
4:
5: if formal:
6: return f"Mr./Ms. {last}"
7: return f"{first} {last}"
Tests:
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: Missing: 3
Line 3 is return "Invalid name". Why is it NOT covered?
Code: check_access.py
1: def check_access(user_role, resource_type, is_owner):
2: # Admin can access anything
3: if user_role == "admin":
4: return True
5:
6: # Owner can access their own resources
7: if is_owner:
8: return True
9:
10: # Editors can access documents and images
11: if user_role == "editor" and resource_type in ["document", "image"]:
12: return True
13:
14: return False
Tests:
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: Missing: 12 (partial)
Line 11-12 is marked as partially covered. Why?
Code: divide.py
1: def safe_divide(a, b):
2: try:
3: result = a / b
4: except ZeroDivisionError:
5: return None
6: except TypeError:
7: return "invalid input"
8: return result
Tests:
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: Missing: 7, 8
Lines 6-7 are the except TypeError block. What input would trigger this exception?
Code: find_max.py
1: def find_max(numbers):
2: if not numbers:
3: return None
4:
5: max_val = numbers[0]
6: for num in numbers[1:]:
7: if num > max_val:
8: max_val = num
9: return max_val
Tests:
def test_single_element():
assert find_max([42]) == 42
def test_sorted_ascending():
assert find_max([1, 2, 3, 4, 5]) == 5
Coverage Report: Missing: 3
Line 3 is return None. Why is the loop (lines 6-8) shown as covered but line 3 is not?
Code: validate_password.py
1: def validate_password(password):
2: if len(password) < 8:
3: return "too short"
4:
5: if len(password) > 128:
6: return "too long"
7:
8: has_upper = any(c.isupper() for c in password)
9: has_lower = any(c.islower() for c in password)
10: has_digit = any(c.isdigit() for c in password)
11:
12: if not has_upper:
13: return "needs uppercase"
14: if not has_lower:
15: return "needs lowercase"
16: if not has_digit:
17: return "needs digit"
18:
19: return "valid"
Coverage Report: Missing: 6, 15, 17
Which test would cover line 15 (return "needs lowercase")?
Code: shipping.py
1: def calculate_shipping(weight, destination, express=False):
2: if weight <= 0:
3: raise ValueError("Weight must be positive")
4:
5: if destination not in ["domestic", "international"]:
6: raise ValueError("Invalid destination")
7:
8: base_rate = 5.0 if destination == "domestic" else 15.0
9: weight_charge = weight * 0.5
10:
11: subtotal = base_rate + weight_charge
12:
13: if express:
14: subtotal *= 1.5
15:
16: if subtotal > 50:
17: discount = subtotal * 0.1
18: return subtotal - discount
19:
20: return subtotal
Coverage Report: Missing: 3, 6, 17, 18
To cover lines 17-18 (discount for subtotal > 50), what’s the MINIMUM weight for domestic non-express?
Code: config.py
1: def parse_config(config_string):
2: if not config_string:
3: return {}
4:
5: result = {}
6: lines = config_string.strip().split('\n')
7:
8: for line in lines:
9: line = line.strip()
10:
11: if not line or line.startswith('#'):
12: continue
13:
14: if '=' not in line:
15: raise ValueError(f"Invalid config line: {line}")
16:
17: key, value = line.split('=', 1)
18: key = key.strip()
19: value = value.strip()
20:
21: if not key:
22: raise ValueError("Empty key not allowed")
23:
24: result[key] = value
25:
26: return result
Coverage Report: Missing: 3, 12, 15, 22
Which input would cover line 22 (raise ValueError("Empty key not allowed"))?
Code: discount.py
1: def calculate_discount(amount, is_member, coupon_code):
2: if amount <= 0:
3: return 0
4:
5: discount = 0.0
6:
7: if is_member:
8: discount += 0.10 # 10% member discount
9:
10: if coupon_code is not None:
11: if coupon_code == "SAVE20":
12: discount += 0.20
13: elif coupon_code == "SAVE10":
14: discount += 0.10
15: elif coupon_code.startswith("VIP"):
16: if is_member:
17: discount += 0.30
18: else:
19: discount += 0.15
20:
21: # Cap discount at 50%
22: if discount > 0.50:
23: discount = 0.50
24:
25: return amount * discount
Coverage Report: Missing: 3, 14, 17, 19, 23
Can line 23 (discount cap = 0.50) ever be reached with this code?
Code: process_order.py
1: def process_order(items, customer_type, promo_code=None):
2: if not items:
3: return {"status": "error", "message": "No items"}
4:
5: subtotal = sum(item["price"] * item["quantity"] for item in items)
6:
7: if subtotal <= 0:
8: return {"status": "error", "message": "Invalid total"}
9:
10: discount_rate = 0
11:
12: if customer_type == "premium":
13: discount_rate = 0.15
14: elif customer_type == "member":
15: discount_rate = 0.05
16:
17: if promo_code == "FLASH50" and subtotal >= 100:
18: discount_rate += 0.50
19: elif promo_code == "FLASH25" and subtotal >= 50:
20: discount_rate += 0.25
21:
22: if discount_rate > 0.60:
23: discount_rate = 0.60
24:
25: discount = subtotal * discount_rate
26: total = subtotal - discount
27:
28: return {"status": "success", ...}
To trigger line 23 (discount cap at 60%), which combination works?