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:

graph TD S["price = 10.0"] --> D1{"age < 16?"} D1 -->|yes| N1["price *= 0.5"] D1 -->|no| D2{"age >= 65?"} D2 -->|yes| N2["price *= 0.7"] D2 -->|no| D3 N1 --> D3{"is_member?"} N2 --> D3 D3 -->|yes| N3["price -= 1.0"] D3 -->|no| D4 N3 --> D4{"day == tuesday?"} D4 -->|yes| N4["price *= 0.9"] D4 -->|no| E["return price"] N4 --> E

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

CriterionRequiresWhat it can still miss
Statement coverageEvery line executed onceThe false side of every if — if (x) guard(); passes without ever testing the unguarded route
Branch coverageEvery decision taken both waysCombinations: D1-true with D3-true might never co-occur
Condition / MC-DCEach sub-condition of compound booleans shown to independently affect the outcomeInteractions between decisions; required for avionics (DO-178C Level A)
Path coverageEvery route end-to-endNothing 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

EcosystemCoverage tool
Pythoncoverage.py (--branch for branch coverage)
RubySimpleCov
JavaJaCoCo
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

  1. 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
  2. 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
  3. Ammann, P. & Offutt, J. (2016). Introduction to Software Testing (2nd ed.). Cambridge University Press. https://cs.gmu.edu/~offutt/softwaretest/