Home

03 Testing Fundamentals: CFG Tracing Exercise

exercise coverage cfg control-flow c0 c1 testing 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 →

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:

Focus areas: Reading CFGs, tracing paths, calculating C0 and C1


Learning Objectives:

Instructions:

  1. Study the code and its corresponding CFG
  2. Trace the given test case(s) through the graph
  3. Identify which nodes and edges are covered
  4. Calculate the coverage percentages
  5. 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

  1. Which nodes are covered by this test?
  2. Which edges are covered by this test?
  3. What is the Statement Coverage (C0)?
  4. What is the Branch Coverage (C1)?
Show Solution

Trace

With input x = 5:

  1. START → N1: Execute if x > 0 → 5 > 0 is True
  2. N1 → N2: Take True edge, execute return "pos"
  3. 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 case
  • x < 0 → Negative case
  • x == 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

  1. Trace both tests through the CFG
  2. What is the combined C0?
  3. What is the combined C1?
  4. 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

  1. Trace both tests and mark covered nodes/edges
  2. Calculate C0 and C1
  3. 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

  1. What is C0 for these two tests?
  2. What is C1 for these two tests?
  3. Is C0 > C1, C0 < C1, or C0 == C1?
  4. 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

  1. Trace the test through the CFG
  2. Calculate C0
  3. Calculate C1
  4. List ALL test cases needed for 100% C1
Show Solution

Trace: test_valid_password (password = “SecurePass1”)

  1. N1: len(“SecurePass1”) = 11, 11 < 8 is False → Go to N3
  2. N3: has_upper = True (has ‘S’ and ‘P’)
  3. N4: has_digit = True (has ‘1’)
  4. N5: not has_upper = not True = False → Go to N7
  5. N7: not has_digit = not True = False → Go to N9
  6. 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

  1. Trace both tests through the CFG
  2. Which branches are still uncovered?
  3. 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

  1. Calculate C0 and C1 for this test suite
  2. Which decision outcomes are NOT covered?
  3. 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

  1. N1 True: Income below 30k
  2. N3 True: Credit below 600
  3. 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:

  1. 100% Statement Coverage (C0)
  2. 100% Branch Coverage (C1)

Questions

  1. Draw the CFG (on paper or mentally trace it)
  2. List all decision points and their branches
  3. Design the minimum test suite for 100% C1
  4. 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:

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

  1. Early returns often create uncovered branches
  2. Nested conditions require tests for each combination
  3. One test path usually covers ~30-50% of branches
  4. 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.

© 2026 Dominik Mueller   •  Powered by Soopr   •  Theme  Moonwalk