From Code to Graphs
Control flow testing uses the structure of code — its branches, loops, and sequences — as the basis for test design. The primary tool is the control flow graph (CFG), which provides a visual representation of all possible execution paths.
Unlike black-box techniques that ignore implementation, control flow testing is a white-box technique that requires access to source code. It ensures that tests exercise the structural elements of the code.
Building a Control Flow Graph
Basic Elements
Every CFG consists of:
- Start node: Entry point of the function
- End node: Exit point(s) of the function
- Process nodes: Sequential statements (rectangles or circles)
- Decision nodes: Branching points — if, switch, ternary (diamonds)
- Junction nodes: Points where branches merge
- Edges: Directed arrows showing flow between nodes
Sequential Statements
Sequential statements that always execute together collapse into a single node:
a = 1
b = 2
c = a + b
This is one node — no branching occurs.
If-Else Structures
if condition: # Decision node
do_something() # True branch node
else:
do_other() # False branch node
result = finish() # Junction node
CFG: Decision → (True edge → do_something → Junction) and (False edge → do_other → Junction) → finish
Loops
while condition: # Decision node (also loop header)
process() # Loop body node
# Back edge returns to condition
post_loop() # Exit from loop
The back edge from the loop body to the condition node is what creates a cycle in the graph.
Switch/Case Statements
match status:
case "active": action_a()
case "pending": action_b()
case "inactive": action_c()
case _: default_action()
A switch creates a decision node with multiple outgoing edges — one per case.
Loop Testing Strategies
Loops are the most error-prone control flow structures. A systematic approach tests loops at their boundaries:
Simple Loop Testing (Beizer’s Loop Testing)
For a loop that can iterate 0 to N times:
| Test | Iterations | Why |
|---|---|---|
| 1 | 0 (skip loop) | Tests the loop guard with immediate false condition |
| 2 | 1 | Minimum execution — catches initialization errors |
| 3 | 2 | Tests transition from first to second iteration |
| 4 | N-1 | Just under maximum — tests near-boundary |
| 5 | N | Maximum iterations — tests termination condition |
| 6 | N+1 (if possible) | Beyond maximum — tests overflow/boundary violation |
Nested Loop Testing
Testing all combinations of nested loops is exponential. The practical approach:
- Start with the innermost loop. Set all outer loops to their minimum values. Test the inner loop using simple loop testing.
- Move outward one level at a time. For each outer loop, fix inner loops at typical values and test the outer loop.
- Test interactions by running the outer loop with the inner loop at boundary values.
Concatenated Loops
Two loops in sequence: test each independently unless the second loop uses data produced by the first. If they share data, test them together.
Dominators and Post-Dominators
Dominator: Node A dominates node B if every path from entry to B passes through A. The entry node dominates all nodes.
Post-dominator: Node A post-dominates B if every path from B to exit passes through A. The exit node post-dominates all nodes.
These concepts help identify:
- Loop headers — nodes that dominate the back edge source
- Natural loops — a back edge from node B to its dominator A defines a natural loop
- Critical edges — edges whose removal disconnects parts of the graph
Practical CFG Construction
For real code, follow this process:
- Number each statement or group sequential statements into blocks
- Mark decision points (if, while, for, switch, ternary, try/catch)
- Draw edges from each block to its successors
- Mark back edges for loops
- Verify: Entry should reach every node; every node should reach exit
Example: Order Processing
def process_order(order): # Node 1: Entry
if not order.items: # Node 2: Decision
return {"error": "Empty order"} # Node 3
total = 0 # Node 4
for item in order.items: # Node 5: Loop decision
if item.quantity <= 0: # Node 6: Decision
continue # Node 7
total += item.price * item.quantity # Node 8
# Back edge to Node 5
if total > 0: # Node 9: Decision
tax = total * 0.1 # Node 10
return {"total": total + tax} # Node 11
else:
return {"error": "Invalid total"} # Node 12
Edges: 1→2, 2→3(T), 2→4(F), 4→5, 5→6(body), 5→9(exit loop), 6→7(T), 6→8(F), 7→5, 8→5, 9→10(T), 9→12(F), 10→11
From this CFG, cyclomatic complexity = 4 (edges - nodes + 2 = 12 - 10 + 2).
Exercise: Build and Test CFGs
Problem 1
Draw the CFG and derive test cases for this function:
def authenticate(username, password, two_factor_code):
user = find_user(username)
if user is None:
return "USER_NOT_FOUND"
if not verify_password(user, password):
user.failed_attempts += 1
if user.failed_attempts >= 3:
lock_account(user)
return "ACCOUNT_LOCKED"
return "WRONG_PASSWORD"
if user.requires_2fa:
if not verify_2fa(user, two_factor_code):
return "INVALID_2FA"
create_session(user)
return "SUCCESS"
Solution
CFG nodes:
- find_user (Entry)
- user is None? (Decision)
- return USER_NOT_FOUND
- verify_password? (Decision)
- failed_attempts += 1
- failed_attempts >= 3? (Decision)
- lock_account + return ACCOUNT_LOCKED
- return WRONG_PASSWORD
- requires_2fa? (Decision)
- verify_2fa? (Decision)
- return INVALID_2FA
- create_session + return SUCCESS
Cyclomatic complexity: 4 decisions + 1 = 5
Test cases (basis paths):
| # | username | password | 2fa_code | Path | Expected |
|---|---|---|---|---|---|
| 1 | “unknown” | any | any | 1→2→3 | USER_NOT_FOUND |
| 2 | “valid” | “wrong” | any | 1→2→4→5→6→8 | WRONG_PASSWORD (attempts < 3) |
| 3 | “valid” | “wrong” | any | 1→2→4→5→6→7 | ACCOUNT_LOCKED (attempts >= 3) |
| 4 | “valid_2fa” | “correct” | “wrong” | 1→2→4→9→10→11 | INVALID_2FA |
| 5 | “valid_2fa” | “correct” | “valid” | 1→2→4→9→10→12 | SUCCESS |
Additional case for no-2FA path:
| 6 | “valid_no2fa” | “correct” | any | 1→2→4→9→12 | SUCCESS |
Problem 2
Apply loop testing to this function:
def find_duplicates(items, max_scan=100):
seen = set()
duplicates = []
for i, item in enumerate(items):
if i >= max_scan:
break
if item in seen:
duplicates.append(item)
else:
seen.add(item)
return duplicates
Solution
Loop testing cases:
| # | items | max_scan | Loop iterations | Tests |
|---|---|---|---|---|
| 1 | [] | 100 | 0 | Empty list — loop never executes |
| 2 | [“a”] | 100 | 1 | Single item — no duplicates possible |
| 3 | [“a”, “a”] | 100 | 2 | Two items — duplicate on second iteration |
| 4 | [“a”,“b”,“c”,“d”,“e”] | 100 | 5 | Multiple items, no duplicates |
| 5 | [“a”,“b”,“a”,“c”,“b”] | 100 | 5 | Multiple items, some duplicates |
| 6 | [“a”,“b”,“c”] | 2 | 2 | max_scan reached before end — tests break condition |
| 7 | [“a”,“b”,“c”] | 0 | 0 | max_scan=0 — loop never executes (same as empty) |
Branch coverage within loop:
item in seen= True (test 3 or 5)item in seen= False (test 2 or 4)i >= max_scan= True (test 6)i >= max_scan= False (tests 1-5)
CFG Analysis in Practice
In real projects, you rarely draw CFGs by hand for every function. Instead:
- Use tools. Many IDEs can visualize control flow. SonarQube reports complexity. Python’s
dismodule shows bytecode flow. - Focus on complex functions. Functions with V(G) > 10 benefit most from CFG analysis.
- Look for structural patterns — deeply nested if-else, loops with multiple exits, exception handling paths.
- Simplify first. If a CFG is too complex to draw, the function should probably be refactored.
Key Takeaways
- Control flow graphs represent all possible execution paths through code
- Nodes are statement blocks; edges are flow between them; back edges create loops
- Loop testing follows Beizer’s strategy: 0, 1, 2, N-1, N, N+1 iterations
- Nested loops: test inner loops first, then work outward
- Dominators identify loop headers and critical structural relationships
- Cyclomatic complexity from the CFG gives the minimum number of basis path tests
- Use CFG analysis primarily for complex functions (V(G) > 10) where intuitive test design is insufficient