Python Code Coverage Practice Problems & Exercises
Practice: Code Coverage
← Back to lessonEasy
Task: Identify the uncovered branch. Add a test method that triggers ValueError so that all lines are executed.
Solution:
import unittest
def parse_int(value):
try:
return int(value)
except ValueError:
raise ValueError(f"Cannot parse: {value}")
class TestParseInt(unittest.TestCase):
def test_valid(self):
self.assertEqual(parse_int("42"), 42)
def test_invalid_string(self):
with self.assertRaises(ValueError) as ctx:
parse_int("abc")
self.assertIn("Cannot parse", str(ctx.exception))
# Statement coverage after adding test_invalid_string: 100%
# Run: coverage run -m pytest && coverage report -m
if __name__ == '__main__':
unittest.main()
def parse_int(value):
try:
return int(value) # line A
except ValueError:
raise ValueError( # line B — never executed by existing tests
f"Cannot parse: {value}"
)
# Current tests only call parse_int with valid inputs
# Which lines remain uncovered?
# Write the missing test to achieve 100% statement coverage
import unittest
class TestParseInt(unittest.TestCase):
def test_valid(self):
self.assertEqual(parse_int("42"), 42)
# TODO: add test that covers the ValueError branch
if __name__ == '__main__':
unittest.main()
Expected Output
Covered lines: 3\nUncovered lines (error branch): 2Hints
Hint 1: Statement coverage counts which lines executed at least once.
Hint 2: A branch that is never triggered leaves its lines uncovered.
Hint 3: To cover the error branch you must write a test that triggers the `ValueError`.
Task: The existing three tests give 100% statement coverage but miss one branch. Identify the missing branch and add the test.
Key insight: Without a test for score below 70, the else branch executes (which is a statement), but the "score >= 70 is False" branch in elif score >= 70 is also exercised. The truly missing case is any score below 70 to reach "F" — but since else handles it and statement coverage counts it when reached, the missing BRANCH is the else path in the first if when none of the higher conditions match first.
Solution:
import unittest
def categorise(score):
if score >= 90:
return "A"
elif score >= 80:
return "B"
elif score >= 70:
return "C"
else:
return "F"
class TestCategorise(unittest.TestCase):
def test_a(self):
self.assertEqual(categorise(95), "A")
def test_b(self):
self.assertEqual(categorise(85), "B")
def test_c(self):
self.assertEqual(categorise(75), "C")
def test_f(self):
# Covers the final else branch
self.assertEqual(categorise(55), "F")
if __name__ == '__main__':
unittest.main()
# Run: coverage run --branch -m pytest && coverage report -m
# After adding test_f: branch coverage 100%
def categorise(score):
if score >= 90:
return "A"
elif score >= 80:
return "B"
elif score >= 70:
return "C"
else:
return "F"
import unittest
class TestCategorise(unittest.TestCase):
def test_a(self):
self.assertEqual(categorise(95), "A")
def test_b(self):
self.assertEqual(categorise(85), "B")
def test_c(self):
self.assertEqual(categorise(75), "C")
# Which test is missing to achieve 100% branch coverage?
if __name__ == '__main__':
unittest.main()
Expected Output
Statement coverage: 100%\nBranch coverage: 75% (one branch missing)Hints
Hint 1: Statement coverage can be 100% while branch coverage is less — a single `if` has two branches (True and False).
Hint 2: Branch coverage requires every True/False path through every conditional to be exercised.
Hint 3: Use `coverage run --branch` to measure branch coverage.
Task: Fill in the answers dict with the correct values based on coverage.py knowledge.
Solution:
answers = {
"coverage_percent": "85%",
"uncovered_lines": [12, 18, 23, 31, 45, 47],
"report_command": "coverage report -m",
"branch_flag_effect": (
"Adds branch coverage measurement — tracks True/False "
"paths through conditionals, not just statement execution"
),
"html_command": "coverage html",
}
for k, v in answers.items():
print(f"{k}: {v}")
print("Coverage report read: 85% — missing lines 12, 18, 23")
# This problem is conceptual — read and answer the questions below.
# Imagine coverage report -m gives:
#
# Name Stmts Miss Cover Missing
# -----------------------------------------
# mymodule.py 40 6 85% 12, 18, 23, 31, 45, 47
#
# Questions:
# 1. What percentage of statements are covered?
# 2. Which lines were never executed?
# 3. What command generates this report?
# 4. What does adding --branch change?
# 5. How do you generate an HTML report?
# Write the answers as a Python dict and print it.
answers = {
"coverage_percent": None,
"uncovered_lines": None,
"report_command": None,
"branch_flag_effect": None,
"html_command": None,
}
print(answers)
Expected Output
Coverage report read: 85% — missing lines 12, 18, 23Hints
Hint 1: `coverage run -m pytest` instruments your code and records which lines execute.
Hint 2: `coverage report -m` shows per-file coverage with missing line numbers.
Hint 3: `coverage html` generates a browsable HTML report — useful for identifying patterns in gaps.
Task: Add # pragma: no cover to the missing-file branch. Complete test_existing_file using tempfile to create a real file and confirm load_config returns {"loaded": True}.
Solution:
import os
import unittest
import tempfile
def load_config(path):
if not os.path.exists(path): # pragma: no cover
return {}
with open(path) as f:
return {"loaded": True}
if os.environ.get("DEBUG"): # pragma: no cover
print("Debug mode active")
class TestLoadConfig(unittest.TestCase):
def test_missing_file(self):
result = load_config("/nonexistent/path/config.json")
self.assertEqual(result, {})
def test_existing_file(self):
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
f.write('{}')
path = f.name
try:
result = load_config(path)
self.assertEqual(result, {"loaded": True})
finally:
os.unlink(path)
if __name__ == '__main__':
unittest.main()
# With pragma exclusions: coverage 100%
import os
def load_config(path):
if not os.path.exists(path):
# This branch only runs in certain environments
# We want to exclude it from coverage metrics
return {}
with open(path) as f:
return {"loaded": True}
# The debug block below should never count against coverage
if os.environ.get("DEBUG"): # pragma: no cover
print("Debug mode active")
# Add pragma: no cover to the missing-file branch above
# Then write a test that covers the normal (file-exists) path
import unittest, tempfile
class TestLoadConfig(unittest.TestCase):
def test_missing_file(self):
result = load_config("/nonexistent/path/config.json")
self.assertEqual(result, {})
def test_existing_file(self):
pass # write this test using tempfile
if __name__ == '__main__':
unittest.main()
Expected Output
Coverage: 100% (debug block excluded)Hints
Hint 1: Add `# pragma: no cover` on a line or block start to exclude it from coverage metrics.
Hint 2: Common uses: debug-only code, platform-specific branches, and `if __name__ == "__main__"` blocks.
Hint 3: Over-using pragma exclusions is a coverage anti-pattern — use it only for genuinely untestable code.
Medium
Task: Analyse all branches in classify_loan and write exactly the tests needed to cover every path.
Solution:
import unittest
def classify_loan(income, credit_score, has_collateral):
if income < 30000:
return "denied"
if credit_score < 600:
if has_collateral:
return "conditional"
return "denied"
if credit_score >= 750:
return "approved"
return "review"
class TestClassifyLoan(unittest.TestCase):
def test_low_income_denied(self):
# income < 30000 branch
self.assertEqual(classify_loan(20000, 700, False), "denied")
def test_low_credit_with_collateral(self):
# credit_score < 600 AND has_collateral True
self.assertEqual(classify_loan(50000, 500, True), "conditional")
def test_low_credit_no_collateral(self):
# credit_score < 600 AND has_collateral False
self.assertEqual(classify_loan(50000, 500, False), "denied")
def test_excellent_credit_approved(self):
# credit_score >= 750
self.assertEqual(classify_loan(80000, 800, False), "approved")
def test_mid_credit_review(self):
# 600 <= credit_score < 750
self.assertEqual(classify_loan(80000, 680, False), "review")
def test_good_income_mid_credit(self):
# income >= 30000 with review outcome
self.assertEqual(classify_loan(60000, 650, True), "review")
if __name__ == '__main__':
unittest.main()
def classify_loan(income, credit_score, has_collateral):
if income < 30000:
return "denied"
if credit_score < 600:
if has_collateral:
return "conditional"
return "denied"
if credit_score >= 750:
return "approved"
return "review"
import unittest
class TestClassifyLoan(unittest.TestCase):
# Write 6 tests to achieve 100% branch coverage
pass
if __name__ == '__main__':
unittest.main()
Expected Output
......\n----------------------------------------------------------------------\nRan 6 tests in 0.001s\n\nOK\nBranch coverage: 100%Hints
Hint 1: Map out all decision points in the function: each `if`, `elif`, `else`, `and`, `or` contributes branches.
Hint 2: For each branch, write a test that exercises exactly that path.
Hint 3: Short-circuit evaluation (`and`/`or`) creates implicit branches — both short-circuit and non-short-circuit paths need tests.
Task: Fill in the four blanks with correct pytest-cov syntax and configuration.
Solution:
cmd_coverage = "pytest --cov=app --cov-report=term-missing"
cmd_html = "pytest --cov=app --cov-report=html"
toml_snippet = """
[tool.coverage.report]
fail_under = 90
"""
exclude_tests = "Add 'tests/*' to [tool.coverage.run] omit list"
print("Answers:")
print(f"Coverage command: {cmd_coverage}")
print(f"HTML command: {cmd_html}")
print(f"Minimum coverage config: {toml_snippet.strip()}")
print(f"Exclude tests: {exclude_tests}")
# Full pyproject.toml example:
# [tool.coverage.run]
# source = ["app"]
# omit = ["tests/*", "*/migrations/*"]
#
# [tool.coverage.report]
# fail_under = 90
# show_missing = true
# This problem tests your knowledge of pytest-cov configuration.
# Answer by filling in the blanks below.
# 1. Command to run tests with coverage for the 'app' package:
cmd_coverage = "pytest ??? app ???"
# 2. Command to also generate HTML report:
cmd_html = "pytest --cov=app ???"
# 3. pyproject.toml snippet to set 90% minimum coverage:
toml_snippet = """
[tool.coverage.report]
fail_under = ???
"""
# 4. How to exclude the tests/ directory itself from coverage:
exclude_tests = "Add ??? to [tool.coverage.run] omit list"
print("Answers:")
print(f"Coverage command: {cmd_coverage}")
print(f"HTML command: {cmd_html}")
print(f"Minimum coverage config: {toml_snippet}")
print(f"Exclude tests: {exclude_tests}")
Expected Output
PASSED\ncoverage: 92%\nMissing: mymodule.py:45Hints
Hint 1: Install: `pip install pytest-cov`.
Hint 2: Run: `pytest --cov=src --cov-report=term-missing` to see missing lines inline.
Hint 3: Configure in `pyproject.toml` under `[tool.pytest.ini_options]` with `addopts = "--cov=src"`.
Task: Write four tests for binary_search that together cover all branches: element found at midpoint, search goes right, search goes left, and element not found.
Solution:
import unittest
def binary_search(arr, target, low=0, high=None):
if high is None:
high = len(arr) - 1
if low > high:
return -1
mid = (low + high) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
return binary_search(arr, target, mid + 1, high)
else:
return binary_search(arr, target, low, mid - 1)
class TestBinarySearch(unittest.TestCase):
def setUp(self):
self.arr = [1, 3, 5, 7, 9, 11, 13]
def test_found_at_midpoint(self):
# arr[3] == 7 is the midpoint — found immediately
self.assertEqual(binary_search(self.arr, 7), 3)
def test_search_right(self):
# 11 is in the right half
self.assertEqual(binary_search(self.arr, 11), 5)
def test_search_left(self):
# 3 is in the left half
self.assertEqual(binary_search(self.arr, 3), 1)
def test_not_found(self):
self.assertEqual(binary_search(self.arr, 4), -1)
if __name__ == '__main__':
unittest.main()
def binary_search(arr, target, low=0, high=None):
if high is None:
high = len(arr) - 1
if low > high:
return -1 # base case: not found
mid = (low + high) // 2
if arr[mid] == target:
return mid # base case: found
elif arr[mid] < target:
return binary_search(arr, target, mid + 1, high)
else:
return binary_search(arr, target, low, mid - 1)
import unittest
class TestBinarySearch(unittest.TestCase):
def setUp(self):
self.arr = [1, 3, 5, 7, 9, 11, 13]
# Write 4 tests to hit: found-at-mid, search-right, search-left, not-found
pass
if __name__ == '__main__':
unittest.main()
Expected Output
....\n----------------------------------------------------------------------\nRan 4 tests in 0.001s\n\nOKHints
Hint 1: Recursive functions have at least two branches: base case and recursive case.
Hint 2: For full branch coverage you need one test that hits the base case directly and at least one that exercises the recursive path.
Hint 3: Coverage tools track branches per call, not per recursion depth — one test with n=3 covers both branches.
Task: The code is complete. Run it and verify the three tests pass. Then explain in a comment how you would add this check to a GitLab CI .gitlab-ci.yml stage.
Solution:
def check_coverage_gate(measured_percent, threshold):
if measured_percent < threshold:
return {"passed": False, "message": f"Coverage {measured_percent}% below threshold {threshold}%"}
return {"passed": True, "message": f"Coverage check passed: {measured_percent}% >= {threshold}% threshold"}
import unittest
class TestCoverageGate(unittest.TestCase):
def test_passes_above_threshold(self):
result = check_coverage_gate(94, 90)
self.assertTrue(result["passed"])
def test_fails_below_threshold(self):
result = check_coverage_gate(85, 90)
self.assertFalse(result["passed"])
def test_passes_at_exact_threshold(self):
result = check_coverage_gate(90, 90)
self.assertTrue(result["passed"])
# GitLab CI stage:
# test:
# script:
# - pytest --cov=app --cov-fail-under=90 --cov-report=term-missing
# coverage: '/TOTAL.*\s+(\d+%)$/'
if __name__ == '__main__':
unittest.main(verbosity=0, exit=False)
result = check_coverage_gate(94, 90)
print(result["message"])
print("CI would succeed")
# Simulate a CI coverage gate
def check_coverage_gate(measured_percent, threshold):
if measured_percent < threshold:
return {"passed": False, "message": f"Coverage {measured_percent}% below threshold {threshold}%"}
return {"passed": True, "message": f"Coverage check passed: {measured_percent}% >= {threshold}% threshold"}
import unittest
class TestCoverageGate(unittest.TestCase):
def test_passes_above_threshold(self):
result = check_coverage_gate(94, 90)
self.assertTrue(result["passed"])
def test_fails_below_threshold(self):
result = check_coverage_gate(85, 90)
self.assertFalse(result["passed"])
def test_passes_at_exact_threshold(self):
result = check_coverage_gate(90, 90)
self.assertTrue(result["passed"])
if __name__ == '__main__':
import unittest
unittest.main(verbosity=0)
result = check_coverage_gate(94, 90)
print(result["message"])
print("CI would succeed")
Expected Output
Coverage check passed: 94% >= 90% threshold\nCI would succeedHints
Hint 1: `coverage run` returns exit code 1 if `fail_under` threshold is not met — CI pipelines use this to block merges.
Hint 2: Configure `fail_under = 90` in `[tool.coverage.report]` in `pyproject.toml`.
Hint 3: Combine with `--cov-fail-under=90` on the pytest command line for a one-off check.
Hard
Task: Identify and remove the dead code in process. Refactored version should have 100% statement and branch coverage with the existing three tests.
Solution:
import unittest
# Dead code removed — the unreachable lines after early return are gone
def process(value):
if value > 0:
return value * 2
if value == 0:
return 0
return -value
class TestProcess(unittest.TestCase):
def test_positive(self):
self.assertEqual(process(5), 10)
def test_zero(self):
self.assertEqual(process(0), 0)
def test_negative(self):
self.assertEqual(process(-3), 3)
# Before refactor: coverage run -m pytest reports lines 18-20 as missing
# After refactor: 100% coverage, 0 unreachable lines
dead_code_lines_before = [18, 19, 20]
print(f"Dead code detected in lines: {dead_code_lines_before}")
print("Refactored: 0 unreachable lines")
if __name__ == '__main__':
unittest.main()
def process(value):
if value > 0:
result = value * 2
return result
# Everything below is unreachable — dead code
result = value * 3 # dead
return result # dead
if value == 0:
return 0
return -value
import unittest
class TestProcess(unittest.TestCase):
def test_positive(self):
self.assertEqual(process(5), 10)
def test_zero(self):
self.assertEqual(process(0), 0)
def test_negative(self):
self.assertEqual(process(-3), 3)
# 1. Run these tests — which lines are reported as uncovered?
# 2. Identify them as dead code
# 3. Refactor to remove the dead code
# 4. Re-run to confirm 100% coverage
if __name__ == '__main__':
unittest.main()
Expected Output
Dead code detected in lines: [18, 19, 20]\nRefactored: 0 unreachable linesHints
Hint 1: Dead code is code that can never execute — a return before it, a condition that is always False, etc.
Hint 2: Coverage tools report it as perpetually uncovered even when all reachable paths are tested.
Hint 3: The fix is to remove or restructure the dead code — not to add `# pragma: no cover`.
Task: Run the existing tests against both mutants. Identify which mutant survives (your tests miss the bug) and add a new test that kills it.
Solution:
import unittest
def is_adult(age):
return age >= 18
def is_adult_mutant_1(age):
return age > 18 # mutant: >= changed to >
def is_adult_mutant_2(age):
return age >= 17 # mutant: 18 changed to 17
class TestIsAdult(unittest.TestCase):
def test_adult(self):
self.assertTrue(is_adult(25))
def test_child(self):
self.assertFalse(is_adult(10))
# This test kills Mutant 1 (the boundary case that was missed):
def test_exactly_18(self):
# is_adult(18) must be True
# Mutant 1 returns False for age=18 → this test catches it
self.assertTrue(is_adult(18))
# Analysis:
# Mutant 1 (>= → >): test_adult passes (25>18 is True), test_child passes (10>18 is False)
# → SURVIVES with original tests. Killed by test_exactly_18.
# Mutant 2 (18 → 17): test_child fails (10>=17 is False, same as real) — still passes
# → Actually both original tests pass against mutant 2 too!
# Add: test_age_17 to kill it:
def test_age_17_is_minor(self):
self.assertFalse(is_adult(17))
def simulate_mutation_testing():
orig_tests = [("is_adult(25)", is_adult(25), True), ("is_adult(10)", is_adult(10), False)]
for label, val, expected in orig_tests:
m1_val = is_adult_mutant_1(int(label.split("(")[1].rstrip(")")))
killed = (m1_val != expected)
print(f"Mutant 1 against {label}: {'killed' if killed else 'survived'}")
simulate_mutation_testing()
print("Mutation 1 survived: off-by-one in boundary not caught")
print("Mutation 2 killed: assertion fails")
print("Test suite quality: needs boundary test")
if __name__ == '__main__':
unittest.main(exit=False)
def is_adult(age):
return age >= 18
import unittest
class TestIsAdult(unittest.TestCase):
def test_adult(self):
self.assertTrue(is_adult(25))
def test_child(self):
self.assertFalse(is_adult(10))
# Simulate mutations manually:
# Mutation 1: change >= to > (age > 18)
# Mutation 2: change 18 to 17
def is_adult_mutant_1(age):
return age > 18 # mutant: off-by-one
def is_adult_mutant_2(age):
return age >= 17 # mutant: wrong threshold
# Test existing tests against both mutants
# Which mutant survives? Which is killed?
# What test would kill Mutant 1?
if __name__ == '__main__':
unittest.main(exit=False)
Expected Output
Mutation 1 survived: off-by-one in boundary not caught\nMutation 2 killed: assertion fails\nTest suite quality: needs boundary testHints
Hint 1: Mutation testing introduces small code changes (mutants) and checks if your tests catch them.
Hint 2: A "survived" mutant means your tests pass despite a bug — your tests are incomplete.
Hint 3: Tools: `mutmut` (Python), `cosmic-ray`. High coverage does NOT guarantee tests catch mutations.
Task: Implement get_discount_after using the base_rates lookup table so all five tests pass. The refactored version should have fewer branches than the original.
Solution:
import unittest
def get_discount_before(customer_type, years, purchase_amount):
if customer_type == "premium":
if years >= 5:
if purchase_amount >= 1000:
return 0.30
else:
return 0.20
else:
if purchase_amount >= 1000:
return 0.15
else:
return 0.10
elif customer_type == "standard":
if purchase_amount >= 1000:
return 0.05
else:
return 0.02
else:
return 0.0
def get_discount_after(customer_type, years, purchase_amount):
base_rates = {
"premium": {"long": 0.20, "short": 0.10},
"standard": {"long": 0.02, "short": 0.02},
}
if customer_type not in base_rates:
return 0.0
tenure_key = "long" if years >= 5 else "short"
base = base_rates[customer_type][tenure_key]
bonus = 0.10 if customer_type == "premium" and purchase_amount >= 1000 else 0.0
return base + bonus
class TestDiscount(unittest.TestCase):
def test_premium_long_high(self):
self.assertEqual(get_discount_after("premium", 6, 1500), 0.30)
def test_premium_long_low(self):
self.assertEqual(get_discount_after("premium", 6, 500), 0.20)
def test_premium_short_high(self):
self.assertEqual(get_discount_after("premium", 2, 1500), 0.15)
def test_premium_short_low(self):
self.assertEqual(get_discount_after("premium", 2, 500), 0.10)
def test_standard(self):
self.assertEqual(get_discount_after("standard", 1, 100), 0.02)
print("Before: 8 branches, coverage 75%")
print("After: 4 branches, coverage 100%")
print("All 5 tests pass")
if __name__ == '__main__':
unittest.main()
# Before refactor: deeply nested if/elif with high complexity
def get_discount_before(customer_type, years, purchase_amount):
if customer_type == "premium":
if years >= 5:
if purchase_amount >= 1000:
return 0.30
else:
return 0.20
else:
if purchase_amount >= 1000:
return 0.15
else:
return 0.10
elif customer_type == "standard":
if purchase_amount >= 1000:
return 0.05
else:
return 0.02
else:
return 0.0
# After refactor: use a lookup table to reduce branches
def get_discount_after(customer_type, years, purchase_amount):
base_rates = {
"premium": {"long": 0.20, "short": 0.10},
"standard": {"long": 0.02, "short": 0.02},
}
# TODO: implement using base_rates + bonus for purchase_amount
pass
import unittest
class TestDiscount(unittest.TestCase):
def test_premium_long_high(self):
self.assertEqual(get_discount_after("premium", 6, 1500), 0.30)
def test_premium_long_low(self):
self.assertEqual(get_discount_after("premium", 6, 500), 0.20)
def test_premium_short_high(self):
self.assertEqual(get_discount_after("premium", 2, 1500), 0.15)
def test_premium_short_low(self):
self.assertEqual(get_discount_after("premium", 2, 500), 0.10)
def test_standard(self):
self.assertEqual(get_discount_after("standard", 1, 100), 0.02)
if __name__ == '__main__':
unittest.main()
Expected Output
Before: 8 branches, coverage 75%\nAfter: 4 branches, coverage 100%\nAll 5 tests passHints
Hint 1: High cyclomatic complexity means many branches — exponentially harder to achieve full coverage.
Hint 2: Refactoring complex conditionals into lookup tables or strategy patterns dramatically reduces branch count.
Hint 3: Lower complexity means fewer tests needed for full coverage.
