03 Testing Fundamentals: CFG Tracing Exercise
December 2025 (4286 Words, 24 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.
CFG Tracing Exercise: Visualizing Statement and Branch Coverage
Introduction
This exercise helps you understand code coverage by tracing test cases through Control Flow Graphs (CFGs). By visualizing which nodes (statements) and edges (branches) are covered, you’ll build intuition for how coverage works.
Preparation: Read First
Before attempting this exercise, study the following lecture sections:
From Chapter 03 (Testing Theory and Coverage): Testing Theory & Coverage:
- Section 4: Code Coverage (C0 and C1) - CFG examples, node/edge counting
- Section 5: Systematic Test Design for Branch Coverage - Deriving tests from CFGs
Focus areas: Reading CFGs, tracing paths, calculating C0 and C1
Learning Objectives:
- Read and understand Control Flow Graphs
- Trace test case execution through a CFG
- Calculate Statement Coverage (C0) from node coverage
- Calculate Branch Coverage (C1) from edge coverage
- Understand why C1 subsumes C0
Instructions:
- Study the code and its corresponding CFG
- Trace the given test case(s) through the graph
- Identify which nodes and edges are covered
- Calculate the coverage percentages
- Click “Show Solution” to verify your answer
Time: 35-45 minutes for all exercises
Exercise 1: Basic CFG - classify_number()
Code
def classify_number(x):
if x > 0: # Node 1 (decision)
return "pos" # Node 2
elif x < 0: # Node 3 (decision)
return "neg" # Node 4
else:
return "zero" # Node 5
Control Flow Graph
flowchart TD
START([Start]) --> N1{x > 0?}
N1 -->|True| N2[return 'pos']
N1 -->|False| N3{x < 0?}
N3 -->|True| N4[return 'neg']
N3 -->|False| N5[return 'zero']
N2 --> END([End])
N4 --> END
N5 --> END
Test Case
def test_positive():
assert classify_number(5) == "pos"
Questions
- Which nodes are covered by this test?
- Which edges are covered by this test?
- What is the Statement Coverage (C0)?
- What is the Branch Coverage (C1)?
Show Solution
Trace
With input x = 5:
- START → N1: Execute
if x > 0→ 5 > 0 is True - N1 → N2: Take True edge, execute
return "pos" - N2 → END: Function returns
Nodes Covered
- N1 (decision check): Covered
- N2 (return “pos”): Covered
- N3 (elif check): NOT covered (short-circuited)
- N4 (return “neg”): NOT covered
- N5 (return “zero”): NOT covered
Edges Covered
| Edge | Covered? |
|---|---|
| START → N1 | Yes |
| N1 → N2 (True) | Yes |
| N1 → N3 (False) | No |
| N3 → N4 (True) | No |
| N3 → N5 (False) | No |
| N2 → END | Yes |
| N4 → END | No |
| N5 → END | No |
Coverage Calculation
Statement Coverage (C0):
\(\text{C0} = \frac{\text{Covered Nodes}}{\text{Total Nodes}} = \frac{2}{5} = 40\%\)
Branch Coverage (C1):
Total branch edges (decision outcomes): 4 (two from N1, two from N3)
\(\text{C1} = \frac{\text{Covered Branches}}{\text{Total Branches}} = \frac{1}{4} = 25\%\)
Key Insight
A single test covers only one path. To achieve 100% coverage, we need tests for:
x > 0→ Positive casex < 0→ Negative casex == 0→ Zero case
Exercise 2: Multiple Test Cases
Code (same as Exercise 1)
def classify_number(x):
if x > 0:
return "pos"
elif x < 0:
return "neg"
else:
return "zero"
Test Cases
def test_positive():
assert classify_number(5) == "pos"
def test_negative():
assert classify_number(-3) == "neg"
Questions
- Trace both tests through the CFG
- What is the combined C0?
- What is the combined C1?
- What test is still needed for 100% coverage?
Show Solution
Trace: test_positive (x = 5)
START → N1 (True) → N2 → END
Nodes covered: N1, N2 Edges covered: START→N1, N1→N2 (True), N2→END
Trace: test_negative (x = -3)
START → N1 (False) → N3 (True) → N4 → END
Nodes covered: N1, N3, N4 Edges covered: START→N1, N1→N3 (False), N3→N4 (True), N4→END
Combined Coverage
Nodes covered: N1, N2, N3, N4 (4 out of 5) Nodes NOT covered: N5 (return “zero”)
Statement Coverage (C0):
\(\text{C0} = \frac{4}{5} = 80\%\)
Edges (branches) covered:
- N1 → N2 (True): Covered
- N1 → N3 (False): Covered
- N3 → N4 (True): Covered
- N3 → N5 (False): NOT covered
Branch Coverage (C1):
\(\text{C1} = \frac{3}{4} = 75\%\)
Missing Test
To achieve 100%, we need:
def test_zero():
assert classify_number(0) == "zero"
This covers:
- N5 (the “zero” return)
- N3 → N5 edge (False branch of
x < 0)
Exercise 3: calculate_grade() - Multiple Branches
Code
def calculate_grade(score):
if score >= 90: # Node 1
return "A" # Node 2
elif score >= 80: # Node 3
return "B" # Node 4
elif score >= 70: # Node 5
return "C" # Node 6
elif score >= 60: # Node 7
return "D" # Node 8
else:
return "F" # Node 9
Control Flow Graph
flowchart TD
START([Start]) --> N1{score >= 90?}
N1 -->|True| N2[return 'A']
N1 -->|False| N3{score >= 80?}
N3 -->|True| N4[return 'B']
N3 -->|False| N5{score >= 70?}
N5 -->|True| N6[return 'C']
N5 -->|False| N7{score >= 60?}
N7 -->|True| N8[return 'D']
N7 -->|False| N9[return 'F']
N2 --> END([End])
N4 --> END
N6 --> END
N8 --> END
N9 --> END
Test Cases
def test_grade_A():
assert calculate_grade(95) == "A"
def test_grade_B():
assert calculate_grade(85) == "B"
Questions
- Trace both tests and mark covered nodes/edges
- Calculate C0 and C1
- How many more tests are needed for 100% C1?
Show Solution
Trace: test_grade_A (score = 95)
Path: START → N1 (True) → N2 → END
Nodes covered: N1, N2
Trace: test_grade_B (score = 85)
Path: START → N1 (False) → N3 (True) → N4 → END
Nodes covered: N1, N3, N4
Combined Coverage
Nodes covered: N1, N2, N3, N4 (4 nodes) Nodes NOT covered: N5, N6, N7, N8, N9 (5 nodes)
Statement Coverage (C0):
\(\text{C0} = \frac{4}{9} = 44\%\)
Branches (decision outcomes):
| Decision | True Edge | False Edge |
|---|---|---|
| N1 (score >= 90) | Covered | Covered |
| N3 (score >= 80) | Covered | Not covered |
| N5 (score >= 70) | Not covered | Not covered |
| N7 (score >= 60) | Not covered | Not covered |
Covered: 3 out of 8 branches
Branch Coverage (C1):
\(\text{C1} = \frac{3}{8} = 37.5\%\)
Tests Needed for 100% C1
We need to cover all 8 branch outcomes. Currently missing:
- N3 False → Need score < 80 but >= 70 (e.g., 75)
- N5 True → Already covered by score 75
- N5 False → Need score < 70 but >= 60 (e.g., 65)
- N7 True → Already covered by score 65
- N7 False → Need score < 60 (e.g., 50)
Minimum additional tests needed: 3
def test_grade_C():
assert calculate_grade(75) == "C"
def test_grade_D():
assert calculate_grade(65) == "D"
def test_grade_F():
assert calculate_grade(50) == "F"
Exercise 4: C0 vs C1 Comparison
Code
def check_eligibility(age, has_license):
if age >= 18: # Node 1
if has_license: # Node 2
return "approved" # Node 3
return "need license" # Node 4
return "too young" # Node 5
Control Flow Graph
flowchart TD
START([Start]) --> N1{age >= 18?}
N1 -->|True| N2{has_license?}
N1 -->|False| N5[return 'too young']
N2 -->|True| N3[return 'approved']
N2 -->|False| N4[return 'need license']
N3 --> END([End])
N4 --> END
N5 --> END
Test Cases
def test_approved():
assert check_eligibility(25, True) == "approved"
def test_need_license():
assert check_eligibility(20, False) == "need license"
Questions
- What is C0 for these two tests?
- What is C1 for these two tests?
- Is C0 > C1, C0 < C1, or C0 == C1?
- What does this tell you about the relationship between C0 and C1?
Show Solution
Trace: test_approved (age=25, has_license=True)
Path: START → N1 (True) → N2 (True) → N3 → END
Nodes covered: N1, N2, N3
Trace: test_need_license (age=20, has_license=False)
Path: START → N1 (True) → N2 (False) → N4 → END
Nodes covered: N1, N2, N4
Combined Node Coverage
Covered: N1, N2, N3, N4 (4 out of 5) NOT covered: N5 (too young path)
Statement Coverage (C0):
\(\text{C0} = \frac{4}{5} = 80\%\)
Branch Coverage
| Decision | True | False |
|---|---|---|
| N1 (age >= 18) | Covered | Not covered |
| N2 (has_license) | Covered | Covered |
Covered: 3 out of 4 branches
Branch Coverage (C1):
\(\text{C1} = \frac{3}{4} = 75\%\)
Comparison
C0 (80%) > C1 (75%)
This demonstrates a key insight:
You can achieve higher statement coverage than branch coverage!
Here’s why:
- Both tests take the N1 True branch (age >= 18)
- This covers 4 statements (N1, N2, N3, N4)
- But the N1 False branch is never taken
- The N5 statement (in the False branch) is never executed
This shows why C1 is stronger than C0:
- 100% C1 → 100% C0 (guaranteed)
- 100% C0 does NOT guarantee 100% C1
Missing Test
def test_too_young():
assert check_eligibility(16, True) == "too young"
This covers:
- N5 (the missing statement)
- N1 → N5 edge (the missing False branch)
Exercise 5: Nested Conditions
Code
def validate_password(password):
if len(password) < 8: # Node 1
return "too short" # Node 2
has_upper = any(c.isupper() for c in password) # Node 3
has_digit = any(c.isdigit() for c in password) # Node 4
if not has_upper: # Node 5
return "need uppercase" # Node 6
if not has_digit: # Node 7
return "need digit" # Node 8
return "valid" # Node 9
Control Flow Graph
flowchart TD
START([Start]) --> N1{len < 8?}
N1 -->|True| N2[return 'too short']
N1 -->|False| N3[has_upper = ...]
N3 --> N4[has_digit = ...]
N4 --> N5{not has_upper?}
N5 -->|True| N6[return 'need uppercase']
N5 -->|False| N7{not has_digit?}
N7 -->|True| N8[return 'need digit']
N7 -->|False| N9[return 'valid']
N2 --> END([End])
N6 --> END
N8 --> END
N9 --> END
Test Case
def test_valid_password():
assert validate_password("SecurePass1") == "valid"
Questions
- Trace the test through the CFG
- Calculate C0
- Calculate C1
- List ALL test cases needed for 100% C1
Show Solution
Trace: test_valid_password (password = “SecurePass1”)
- N1: len(“SecurePass1”) = 11, 11 < 8 is False → Go to N3
- N3: has_upper = True (has ‘S’ and ‘P’)
- N4: has_digit = True (has ‘1’)
- N5: not has_upper = not True = False → Go to N7
- N7: not has_digit = not True = False → Go to N9
- N9: return “valid”
Path: START → N1 (F) → N3 → N4 → N5 (F) → N7 (F) → N9 → END
Nodes Covered
Covered: N1, N3, N4, N5, N7, N9 (6 nodes) NOT covered: N2, N6, N8 (3 nodes)
Statement Coverage (C0):
\(\text{C0} = \frac{6}{9} = 67\%\)
Branches Covered
| Decision | True | False |
|---|---|---|
| N1 (len < 8) | Not covered | Covered |
| N5 (not has_upper) | Not covered | Covered |
| N7 (not has_digit) | Not covered | Covered |
Covered: 3 out of 6 branches
Branch Coverage (C1):
\(\text{C1} = \frac{3}{6} = 50\%\)
Tests for 100% C1
We need to cover the True branch of each decision:
1. N1 True (password too short):
def test_too_short():
assert validate_password("Short1") == "too short" # len = 6
2. N5 True (missing uppercase):
def test_need_uppercase():
assert validate_password("lowercase1") == "need uppercase"
3. N7 True (missing digit):
def test_need_digit():
assert validate_password("NoDigitHere") == "need digit"
Complete test suite for 100% C1:
def test_valid():
assert validate_password("SecurePass1") == "valid"
def test_too_short():
assert validate_password("Short1") == "too short"
def test_need_uppercase():
assert validate_password("lowercase1") == "need uppercase"
def test_need_digit():
assert validate_password("NoDigitHere") == "need digit"
Exercise 6: Early Returns
Code
def calculate_discount(amount, is_member, coupon_code):
if amount <= 0: # Node 1
return 0 # Node 2
discount = 0 # Node 3
if is_member: # Node 4
discount += 10 # Node 5
if coupon_code == "SAVE20": # Node 6
discount += 20 # Node 7
elif coupon_code == "SAVE10": # Node 8
discount += 10 # Node 9
return amount * (discount / 100) # Node 10
Control Flow Graph
flowchart TD
START([Start]) --> N1{amount <= 0?}
N1 -->|True| N2[return 0]
N1 -->|False| N3[discount = 0]
N3 --> N4{is_member?}
N4 -->|True| N5[discount += 10]
N4 -->|False| N6a[skip]
N5 --> N6{coupon == 'SAVE20'?}
N6a --> N6
N6 -->|True| N7[discount += 20]
N6 -->|False| N8{coupon == 'SAVE10'?}
N7 --> N10[return calculated]
N8 -->|True| N9[discount += 10]
N8 -->|False| N10
N9 --> N10
N2 --> END([End])
N10 --> END
Test Cases
def test_member_with_save20():
result = calculate_discount(100, True, "SAVE20")
assert result == 30 # 10% member + 20% coupon
def test_non_member_no_coupon():
result = calculate_discount(100, False, None)
assert result == 0 # No discounts
Questions
- Trace both tests through the CFG
- Which branches are still uncovered?
- How many tests are needed for 100% C1?
Show Solution
Trace: test_member_with_save20
Input: amount=100, is_member=True, coupon_code=”SAVE20”
Path: N1(F) → N3 → N4(T) → N5 → N6(T) → N7 → N10 → END
Covered: N1, N3, N4, N5, N6, N7, N10
Trace: test_non_member_no_coupon
Input: amount=100, is_member=False, coupon_code=None
Path: N1(F) → N3 → N4(F) → N6(F) → N8(F) → N10 → END
Covered: N1, N3, N4, N6, N8, N10
Combined Coverage
Nodes covered: N1, N3, N4, N5, N6, N7, N8, N10 (8 nodes) Nodes NOT covered: N2, N9 (2 nodes)
C0: 8/10 = 80%
Branches:
| Decision | True | False |
|---|---|---|
| N1 (amount <= 0) | No | Yes |
| N4 (is_member) | Yes | Yes |
| N6 (coupon == SAVE20) | Yes | Yes |
| N8 (coupon == SAVE10) | No | Yes |
C1: 6/8 = 75%
Missing Tests for 100% C1
1. N1 True (negative/zero amount):
def test_invalid_amount():
assert calculate_discount(0, True, "SAVE20") == 0
2. N8 True (SAVE10 coupon):
def test_save10_coupon():
result = calculate_discount(100, False, "SAVE10")
assert result == 10
Minimum tests for 100% C1: 4 total
Exercise 7: Complex Conditions (Advanced)
Code
def approve_loan(income, credit_score, has_collateral):
# Automatic rejection
if income < 30000: # Node 1
return "rejected: low income" # Node 2
if credit_score < 600: # Node 3
return "rejected: poor credit" # Node 4
# Premium approval
if income >= 100000 and credit_score >= 750: # Node 5
return "approved: premium" # Node 6
# Standard approval with collateral
if credit_score >= 700 or has_collateral: # Node 7
return "approved: standard" # Node 8
return "manual review required" # Node 9
Control Flow Graph
flowchart TD
START([Start]) --> N1{income < 30k?}
N1 -->|True| N2[rejected: low income]
N1 -->|False| N3{credit < 600?}
N3 -->|True| N4[rejected: poor credit]
N3 -->|False| N5{income>=100k AND credit>=750?}
N5 -->|True| N6[approved: premium]
N5 -->|False| N7{credit>=700 OR collateral?}
N7 -->|True| N8[approved: standard]
N7 -->|False| N9[manual review]
N2 --> END([End])
N4 --> END
N6 --> END
N8 --> END
N9 --> END
Test Suite
def test_premium_approval():
assert approve_loan(150000, 800, False) == "approved: premium"
def test_standard_with_good_credit():
assert approve_loan(50000, 720, False) == "approved: standard"
def test_standard_with_collateral():
assert approve_loan(50000, 650, True) == "approved: standard"
Questions
- Calculate C0 and C1 for this test suite
- Which decision outcomes are NOT covered?
- Design additional tests to achieve 100% C1
Show Solution
Trace All Tests
test_premium_approval (income=150000, credit=800, collateral=False):
N1(F) → N3(F) → N5(T) → N6 → END
test_standard_with_good_credit (income=50000, credit=720, collateral=False):
N1(F) → N3(F) → N5(F) → N7(T) → N8 → END
test_standard_with_collateral (income=50000, credit=650, collateral=True):
N1(F) → N3(F) → N5(F) → N7(T) → N8 → END
Coverage Analysis
Nodes covered: N1, N3, N5, N6, N7, N8 (6 nodes) Nodes NOT covered: N2, N4, N9 (3 nodes)
C0: 6/9 = 67%
Branches:
| Decision | True | False |
|---|---|---|
| N1 (income < 30k) | No | Yes |
| N3 (credit < 600) | No | Yes |
| N5 (income>=100k AND credit>=750) | Yes | Yes |
| N7 (credit>=700 OR collateral) | Yes | No |
C1: 5/8 = 62.5%
Uncovered Decision Outcomes
- N1 True: Income below 30k
- N3 True: Credit below 600
- N7 False: Credit < 700 AND no collateral
Additional Tests for 100% C1
def test_rejected_low_income():
# Covers N1 True, N2
assert approve_loan(20000, 750, True) == "rejected: low income"
def test_rejected_poor_credit():
# Covers N3 True, N4
assert approve_loan(50000, 500, True) == "rejected: poor credit"
def test_manual_review():
# Covers N7 False, N9
# Needs: income >= 30k, credit >= 600 but < 700, no collateral
assert approve_loan(50000, 650, False) == "manual review required"
Complete test suite for 100% C1: 6 tests
Exercise 8: Design Your Own Tests
Code
def process_order(total, user_type, promo_code):
if total <= 0: # Node 1
return "invalid order" # Node 2
base_discount = 0 # Node 3
if user_type == "premium": # Node 4
base_discount = 15 # Node 5
elif user_type == "member": # Node 6
base_discount = 5 # Node 7
if promo_code == "FLASH50": # Node 8
if total >= 100: # Node 9
return f"discount: 50%" # Node 10
return "promo requires $100+" # Node 11
if base_discount > 0: # Node 12
return f"discount: {base_discount}%" # Node 13
return "no discount" # Node 14
Challenge
Without a pre-made CFG or tests, design a test suite that achieves:
- 100% Statement Coverage (C0)
- 100% Branch Coverage (C1)
Questions
- Draw the CFG (on paper or mentally trace it)
- List all decision points and their branches
- Design the minimum test suite for 100% C1
- Verify your coverage by tracing each test
Show Solution
Decision Points and Branches
| Node | Decision | True Branch | False Branch |
|---|---|---|---|
| N1 | total <= 0 | N2 | N3 |
| N4 | user_type == “premium” | N5 | N6 |
| N6 | user_type == “member” | N7 | N8 |
| N8 | promo_code == “FLASH50” | N9 | N12 |
| N9 | total >= 100 | N10 | N11 |
| N12 | base_discount > 0 | N13 | N14 |
Total branches: 12
Minimum Test Suite for 100% C1
# Test 1: Invalid order (N1 True)
def test_invalid_order():
assert process_order(0, "guest", None) == "invalid order"
# Test 2: Premium user, no promo (N4 True, N8 False, N12 True)
def test_premium_no_promo():
assert process_order(50, "premium", None) == "discount: 15%"
# Test 3: Member user, no promo (N4 False, N6 True, N12 True)
def test_member_no_promo():
assert process_order(50, "member", None) == "discount: 5%"
# Test 4: Guest, FLASH50 promo, high total (N4 False, N6 False, N8 True, N9 True)
def test_flash50_high_total():
assert process_order(150, "guest", "FLASH50") == "discount: 50%"
# Test 5: Guest, FLASH50 promo, low total (N8 True, N9 False)
def test_flash50_low_total():
assert process_order(50, "guest", "FLASH50") == "promo requires $100+"
# Test 6: Guest, no promo (N8 False, N12 False)
def test_guest_no_promo():
assert process_order(50, "guest", None) == "no discount"
Total tests needed: 6
Verification
Trace each test to confirm all 14 nodes and 12 branches are covered:
| Test | Nodes Covered | New Branches |
|---|---|---|
| 1 | N1, N2 | N1→N2 |
| 2 | N1, N3, N4, N5, N8, N12, N13 | N1→N3, N4→N5, N8→N12, N12→N13 |
| 3 | N1, N3, N4, N6, N7, N8, N12, N13 | N4→N6, N6→N7 |
| 4 | N1, N3, N4, N6, N8, N9, N10 | N6→N8, N8→N9, N9→N10 |
| 5 | N1, N3, N4, N6, N8, N9, N11 | N9→N11 |
| 6 | N1, N3, N4, N6, N8, N12, N14 | N12→N14 |
All 14 nodes covered: C0 = 100% All 12 branches covered: C1 = 100%
Summary
What You’ve Learned
After completing these exercises, you should be able to:
- Read and interpret Control Flow Graphs
- Trace test case execution through code paths
- Calculate Statement Coverage (C0) by counting covered nodes
- Calculate Branch Coverage (C1) by counting covered edges
- Design tests to achieve specific coverage goals
- Understand why C1 subsumes C0
Key Formulas
Statement Coverage (C0):
\(\text{C0} = \frac{\text{Nodes Executed}}{\text{Total Nodes}} \times 100\%\)
Branch Coverage (C1):
\(\text{C1} = \frac{\text{Edges Taken}}{\text{Total Decision Edges}} \times 100\%\)
Common Patterns
- Early returns often create uncovered branches
- Nested conditions require tests for each combination
- One test path usually covers ~30-50% of branches
- 100% C1 typically needs one test per distinct outcome
What’s Next?
Continue with the Coverage Detective Exercise to practice analyzing real pytest-cov reports and designing tests to cover missing code.