Programming for coverage part 6

Programming for coverage part 6

September 8, 2025

Drawing insights

We can draw interesting insights from measuring code coverage. This is a binary search that I borrowed from the internet and used in my talk on prime path coverage in GCC at TechTown 2024, very slightly revised. It returns the index of the needle (x) in a sorted array, or not-found (-1) if it could not be found.

int bs(const int A[], int N, int x) {
    int min = 0;
    int max = N - 1;
    int mid = min + ((max - min) / 2);
    while (A[mid] != x && min <= max) {
        mid = min + ((max - min) / 2);
        if (x > A[mid])
            min = mid + 1;
        else
            max = mid - 1;
    }

    return A[mid] == x ? mid : -1;
}

We add tests for the cases we can think of along with the expected results. All good so far, no real problems, and the tests looks solid.

void check() {
    static const int haystack[] = {
        1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144,
        233, 377, 610, 987, 1597, 2584, 4181,
    };

    const int len = sizeof (haystack) / sizeof (*haystack);

    // smaller than smallest
    assert(bs(haystack, len, 0) == -1);
    // hit left
    assert(bs(haystack, len, 2) == 1);
    // miss left
    assert(bs(haystack, len, 4) == -1);

    // larger than largest
    assert(bs(haystack, len, 5000) == -1);
    // hit right
    assert(bs(haystack, len, 1597) == 15);
    // miss right
    assert(bs(haystack, len, 1024) == -1);
}

Measuring coverage

We measure the coverage and are surprised to find that we have 100% block- and edge coverage, but just ~63% prime path coverage, with 15/24 paths covered. If we search for all values between 0 and 5000, which covers the whole haystack with margins on either side we end up covering 16/24 paths, just one more than our hand-written set. We need to understand why coverage is still so poor. This is the CFG from zcov:

bs-1
Original binary search

Path 2, 3

These are the paths where min > max on the first iteration, which can only happen when N <= 0. N is the array length, so a negative value does not make sense. These are both inputs we simply didn’t test for, but that we surely want to handle correctly by always returning not-found, and making sure we’re not accessing memory out of bounds.

bs-1-path-2
Path 2
bs-1-path-3
Path 3

Path 5, 23

We found the value we’re looking for, but we’re testing if we should return not-found (-1). This is a contradiction; since we broke the loop on A[mid] == x, we can never see A[mid] != x in the ternary.

bs-1-path-5
Path 5
bs-1-path-23
Path 23

Path 7, 10

These are also contradictions. In these paths we break the loop on min > max, which is checked after A[mid] != x => true. Since A[mid] == x and A[mid] != x can’t be true at the same time, we cannot cover these paths.

bs-1-path-7
Path 7
bs-1-path-10
Path 10

Path 19, 20

These paths are hard to distinguish from 7, 10; they start in block 7, which is the comparison min <= max. In these paths, x > A[mid] is true, but the loop immediately breaks on A[mid] != x => false, which implies A[mid] == x => true. This is a contradiction; x > A[mid] and A[mid] == x cannot be true simultaneously, and these paths cannot be covered.

bs-1-path-19
Path 19
bs-1-path-20
Path 20

Rewriting the algorithm

The key insight from measuring the coverage is that we have a lot of redundant checks. Poor coverage aside, this is a common source of bugs. When we read code we often simulate the execution, draw mental data flow diagrams and compose long chains of reasoning. Even when the comparisons are reused they form yet another branching point, just like the control flow graph. We can rewrite the binary search with the new insights in mind and eliminate the redundant comparisons.

We move the A[mid] == x check into the loop. mid is no longer carried between iterations, and the value can be moved into the loop, too. If we find the value we’re looking for we immediately return as there is nothing more to do, which eliminates the ternary in the return statement.

int bs(const int A[], int N, int x) {
    int min = 0;
    int max = N - 1;
    while (min <= max) {
        int mid = min + ((max - min) / 2);
        if (x == A[mid])
            return mid;
        else if (x > A[mid])
            min = mid + 1;
        else
            max = mid - 1;
    }

    return -1;
}

We test it using the same checks and measure the coverage. This version has 16/18 (89%) paths covered, and we can quicky spot the missing test cases with zcov. These are the missing cases, paths 0 and 3.

bs-2-path-0
Path 0
bs-2-path-3
Path 3

These are the cases where the needle is in the exact middle (path 0) and where the loop is never entered (the empty list). Both of these are oversights that should clearly be a part of the functional testing suite. We add these checks to complete our test suite and measure to verify that 18/18 paths are covered.

    // missing tests:
    // empty list
    assert(bs(haystack, 0, 1) == -1);
    // mid hit
    assert(bs(haystack, len, 55) == 8);

The new function violates MISRA (only a single return per function), but I find that perfectly acceptable and better than the alternatives.