Code Path Analysis
Black-box testing asks whether the code meets its specification; code path analysis is the white-box counterpart — it reads the code's structure and asks: which routes through this logic have my tests actually walked? It is how you find the branch nobody has ever executed, and how you decide, defensibly, how many tests a piece of logic deserves. (For where this sits among the other techniques, see Types of Testing.)
Control Flow Graphs
Every function can be drawn as a control flow graph (CFG): nodes are straight-line chunks of code, edges are the possible jumps between them. Here is a cinema pricing function and its CFG:
Cyclomatic Complexity
McCabe's cyclomatic complexity counts the independent paths through a CFG [1]:
\( M = E - N + 2P \) (edges − nodes + 2 × connected components), or more practically: number of decisions + 1.
Our function has four decisions (D1–D4), so \( M = 5 \). That number is useful twice over:
- As a test budget: M is the size of a basis path set — a set of paths from which every possible route can be composed. Five decisions ⇒ you need at least five tests for branch-level confidence.
- As a design smell: M grows with nesting and branching. A common rule of thumb treats M > 10 as a refactoring prompt [1] — not because 11 is dangerous, but because the test budget (and human working memory) scales with it.
The Coverage Criteria Ladder
| Criterion | Requires | What it can still miss |
|---|---|---|
| Statement coverage | Every line executed once | The false side of every if — if (x) guard(); passes without ever testing the unguarded route |
| Branch coverage | Every decision taken both ways | Combinations: D1-true with D3-true might never co-occur |
| Condition / MC-DC | Each sub-condition of compound booleans shown to independently affect the outcome | Interactions between decisions; required for avionics (DO-178C Level A) |
| Path coverage | Every route end-to-end | Nothing structural — but paths explode combinatorially (loops make them infinite), so it is a limit, not a target |
Basis path testing is the practical compromise: cover the M independent paths and you have branch coverage plus the confidence that every decision has been exercised in context.
From Paths to Tests
Choose a baseline path (all decisions false), then flip one decision at a time — each flip is one basis path and one test:
def ticket_price(age, is_member, day):
price = 10.0
if age < 16: # D1
price *= 0.5
elif age >= 65: # D2
price *= 0.7
if is_member: # D3
price -= 1.0
if day == "tuesday": # D4
price *= 0.9
return round(price, 2)
# One test per basis path (M = 5)
def test_adult_baseline(): # all decisions false
assert ticket_price(30, False, "monday") == 10.00
def test_child_rate(): # D1 true
assert ticket_price(10, False, "monday") == 5.00
def test_senior_rate(): # D2 true
assert ticket_price(70, False, "monday") == 7.00
def test_member_discount(): # D3 true
assert ticket_price(30, True, "monday") == 9.00
def test_tuesday_offer(): # D4 true
assert ticket_price(30, False, "tuesday") == 9.00
double ticket_price(int age, bool is_member, const std::string& day) {
double price = 10.0;
if (age < 16) { // D1
price *= 0.5;
} else if (age >= 65) { // D2
price *= 0.7;
}
if (is_member) { // D3
price -= 1.0;
}
if (day == "tuesday") { // D4
price *= 0.9;
}
return std::round(price * 100) / 100.0;
}
// One test per basis path (M = 5)
TEST_CASE("adult baseline") { CHECK(ticket_price(30, false, "monday") == 10.00); }
TEST_CASE("child rate") { CHECK(ticket_price(10, false, "monday") == 5.00); }
TEST_CASE("senior rate") { CHECK(ticket_price(70, false, "monday") == 7.00); }
TEST_CASE("member discount") { CHECK(ticket_price(30, true, "monday") == 9.00); }
TEST_CASE("tuesday offer") { CHECK(ticket_price(30, false, "tuesday") == 9.00); }
double ticketPrice(int age, boolean isMember, String day) {
double price = 10.0;
if (age < 16) { // D1
price *= 0.5;
} else if (age >= 65) { // D2
price *= 0.7;
}
if (isMember) { // D3
price -= 1.0;
}
if (day.equals("tuesday")) { // D4
price *= 0.9;
}
return Math.round(price * 100) / 100.0;
}
// One test per basis path (M = 5)
@Test void adultBaseline() { assertEquals(10.00, ticketPrice(30, false, "monday")); }
@Test void childRate() { assertEquals(5.00, ticketPrice(10, false, "monday")); }
@Test void seniorRate() { assertEquals(7.00, ticketPrice(70, false, "monday")); }
@Test void memberDiscount() { assertEquals(9.00, ticketPrice(30, true, "monday")); }
@Test void tuesdayOffer() { assertEquals(9.00, ticketPrice(30, false, "tuesday")); }
double TicketPrice(int age, bool isMember, string day)
{
var price = 10.0;
if (age < 16) // D1
price *= 0.5;
else if (age >= 65) // D2
price *= 0.7;
if (isMember) // D3
price -= 1.0;
if (day == "tuesday") // D4
price *= 0.9;
return Math.Round(price, 2);
}
// One test per basis path (M = 5)
[Fact] public void AdultBaseline() => Assert.Equal(10.00, TicketPrice(30, false, "monday"));
[Fact] public void ChildRate() => Assert.Equal(5.00, TicketPrice(10, false, "monday"));
[Fact] public void SeniorRate() => Assert.Equal(7.00, TicketPrice(70, false, "monday"));
[Fact] public void MemberDiscount() => Assert.Equal(9.00, TicketPrice(30, true, "monday"));
[Fact] public void TuesdayOffer() => Assert.Equal(9.00, TicketPrice(30, false, "tuesday"));
def ticket_price(age, member, day)
price = 10.0
if age < 16 # D1
price *= 0.5
elsif age >= 65 # D2
price *= 0.7
end
price -= 1.0 if member # D3
price *= 0.9 if day == "tuesday" # D4
price.round(2)
end
# One test per basis path (M = 5)
RSpec.describe "ticket_price" do
it("charges adults full price") { expect(ticket_price(30, false, "monday")).to eq(10.00) }
it("halves the price for children") { expect(ticket_price(10, false, "monday")).to eq(5.00) }
it("discounts seniors to 70%") { expect(ticket_price(70, false, "monday")).to eq(7.00) }
it("takes £1 off for members") { expect(ticket_price(30, true, "monday")).to eq(9.00) }
it("applies the Tuesday offer") { expect(ticket_price(30, false, "tuesday")).to eq(9.00) }
end
Notice what the analysis bought: the test list was derived, not brainstormed, and anyone reviewing it can check completeness against the CFG rather than against your imagination.
Tooling — and the Limit of Coverage
| Ecosystem | Coverage tool |
|---|---|
| Python | coverage.py (--branch for branch coverage) |
| Ruby | SimpleCov |
| Java | JaCoCo |
| C# | coverlet |
| C/C++ | gcov / llvm-cov |
One warning belongs in bold: coverage measures execution, not verification. A test that calls ticket_price and asserts nothing scores 100% while checking nothing. The tool that closes that gap is mutation testing.
References
- McCabe, T.J. (1976). "A Complexity Measure." IEEE Transactions on Software Engineering, SE-2(4), 308–320. https://doi.org/10.1109/TSE.1976.233837
- Watson, A.H. & McCabe, T.J. (1996). Structured Testing: A Testing Methodology Using the Cyclomatic Complexity Metric. NIST Special Publication 500-235. https://www.mccabe.com/pdf/mccabe-nist235r.pdf
- Ammann, P. & Offutt, J. (2016). Introduction to Software Testing (2nd ed.). Cambridge University Press. https://cs.gmu.edu/~offutt/softwaretest/