06 · Code Coverage (gcov/lcov)¶
Coverage answers one question: which lines did the test suite execute? That is useful and it is narrow. Coverage cannot tell you whether the assertions were meaningful, only whether the code ran. This module shows how to measure it, how to read the numbers honestly, and — with a worked example — why 100% line coverage routinely hides untested logic.
1. Instrumenting a build¶
GCC and Clang both implement gcov-style instrumentation behind --coverage,
which is shorthand for -fprofile-arcs -ftest-coverage at compile time and
-lgcov at link time. Pass it to both steps.
/* grade.c */
#include "grade.h"
char grade_for(int score) {
if (score < 0 || score > 100) return '?';
if (score >= 90) return 'A';
if (score >= 80) return 'B';
if (score >= 70) return 'C';
return 'F';
}
/* test_grade.c */
#include <assert.h>
#include <stdio.h>
#include "grade.h"
int main(void) {
assert(grade_for(95) == 'A');
assert(grade_for(85) == 'B');
assert(grade_for(50) == 'F');
printf("all assertions passed\n");
return 0;
}
gcc-16 --coverage -O0 -g -c grade.c -o grade.o
gcc-16 --coverage -O0 -g -c test_grade.c -o test_grade.o
gcc-16 --coverage grade.o test_grade.o -o test_grade
./test_grade
Compiling produces grade.gcno (the control-flow graph). Running produces
grade.gcda (the execution counts). Both are needed.
Compile and link as separate steps
Doing it in one command — gcc --coverage -O0 -g grade.c test_grade.c -o t —
names the notes files after the output binary, e.g. t-grade.gcno, and
plain gcov grade.c then reports grade.gcno: No such file or directory.
This is a real and confusing failure. Compile to .o files first, which is
what CMake and Make do anyway.
Also non-negotiable: -O0. Optimisation merges, reorders and deletes lines,
so counts stop mapping to your source.
2. Reading a gcov report¶
100% line coverage, three test cases, five possible return values. Something is clearly missing — and line coverage cannot see it. Ask for branches:
File 'grade.c'
Lines executed:100.00% of 6
Branches executed:100.00% of 10
Taken at least once:70.00% of 10
No calls
There it is. 100% of lines, 70% of branches. The annotated source
(grade.c.gcov) names the gaps exactly:
function grade_for called 3 returned 100% blocks executed 82%
3: 2:char grade_for(int score) {
3*: 3: if (score < 0 || score > 100) return '?';
branch 0 taken 100% (fallthrough)
branch 1 taken 0%
branch 2 taken 0% (fallthrough)
branch 3 taken 100%
3: 4: if (score >= 90) return 'A';
branch 0 taken 33% (fallthrough)
branch 1 taken 67%
2: 5: if (score >= 80) return 'B';
branch 0 taken 50% (fallthrough)
branch 1 taken 50%
1*: 6: if (score >= 70) return 'C';
branch 0 taken 0% (fallthrough)
branch 1 taken 100%
1: 7: return 'F';
Two findings you could not get from the line number:
- Line 3:
branch 1 taken 0%andbranch 2 taken 0%— the validation path never ran. Nothing testedgrade_for(-5)orgrade_for(150). The'?'return is dead as far as the suite is concerned. - Line 6:
branch 0 taken 0%—'C'is never returned. No input in the 70–79 range was tested.
The * suffix on 3*: and 1*: is gcov's own hint that the line contains at
least one branch that was never taken.
Adding grade_for(-1), grade_for(101) and grade_for(75) takes branch
coverage to 100% and, more to the point, actually tests two whole behaviours the
original suite ignored.
3. What the coverage types measure¶
| Type | Question | Catches | Misses |
|---|---|---|---|
| Line / statement | Did this line run? | Wholly untested code | Untaken branches, as above |
| Branch / decision | Did each if go both ways? |
The '?' and 'C' gaps |
Which sub-condition mattered |
| Condition | Did each sub-expression take both values? | score < 0 never true in a \|\| b |
Combinations |
| MC/DC | Did each condition independently affect the outcome? | Redundant conditions | — |
| Function | Was each function called? | Dead functions | Everything inside them |
| Path | Was each route through the function taken? | Interaction bugs | Impractical: exponential |
For most projects, branch coverage is the right target. MC/DC is mandated for safety-critical work (DO-178C level A, ISO 26262 ASIL D) and is expensive everywhere else.
4. HTML reports with lcov¶
gcov reports one file at a time. lcov aggregates, and genhtml renders.
Found 2 data files in .
Finished processing 2 GCDA files
Summary coverage rate:
source files: 2
lines.......: 100.0% (12 of 12 lines)
functions...: 100.0% (2 of 2 functions)
Filter out noise, then render:
lcov --remove cov.info '/usr/*' '*/_deps/*' '*/tests/*' --output-file cov.info
genhtml cov.info --branch-coverage --output-directory coverage-html
open coverage-html/index.html # xdg-open on Linux
Match your gcov to your compiler
gcov files are versioned. Running Apple's /usr/bin/gcov against data
produced by Homebrew GCC 16 gives version mismatch or garbage. If you
compiled with gcc-16, pass --gcov-tool $(which gcov-16) to lcov. For
Clang builds, use llvm-cov gcov via a small wrapper script, or switch to
Clang's native -fprofile-instr-generate -fcoverage-mapping and
llvm-profdata / llvm-cov show.
Also always --remove third-party and test code. Test files are executed by
definition, so leaving them in inflates the number and hides gaps in the code
you care about.
5. Wiring it into CMake¶
option(ENABLE_COVERAGE "Build with gcov instrumentation" OFF)
if(ENABLE_COVERAGE)
add_compile_options(--coverage -O0 -g)
add_link_options(--coverage)
endif()
cmake -S . -B build-cov -DENABLE_COVERAGE=ON
cmake --build build-cov
ctest --test-dir build-cov
lcov --capture --directory build-cov --output-file cov.info
genhtml cov.info --branch-coverage --output-directory coverage-html
Use a separate build directory: .gcda files accumulate counts across runs,
so a stale directory silently reports coverage from tests you deleted. Delete
the .gcda files (lcov --zerocounters --directory build-cov) before each
measured run.
6. Using the number honestly¶
Coverage is a gap-finding tool, not a quality score. Two properties make it easy to abuse:
- It is trivially gameable. A test that calls every function and asserts nothing reaches 100%. Some teams have discovered this by accident and then defended the number.
- It measures execution, not verification. A line can run inside a test that never checks its effect.
Practical policy:
- Track branch coverage, not line coverage.
- Enforce a ratchet — coverage may not drop below its current value — rather than an absolute target. Absolute targets get met by writing assertion-free tests for trivial getters.
- Read the annotated source for changed files in review; that is where the signal is. The percentage is for trend lines, not for decisions.
- Treat uncovered error-handling paths as the top priority. They are the least exercised in production and the most likely to be wrong.
- Never let coverage substitute for the mutation question: if I broke this line, would a test fail?
How It Actually Works: how .gcda counters get produced and merged¶
-fprofile-arcs -ftest-coverage(or--coverage) instruments control-flow edges, not lines. GCC/Clang build a control-flow graph for each function at compile time and inject a hidden counter increment on each arc between basic blocks. Because every possible path through a function is a sequence of arcs, the compiler needs to instrument only a spanning set of arcs (not literally all of them) to be able to derive every other arc's count arithmetically — this is why coverage instrumentation overhead is modest even though every branch is theoretically tracked..gcnoand.gcdasplit static structure from dynamic counts. The.gcnofile, written at compile time, records the CFG shape and source line mapping for each function. The.gcdafile, written when the instrumented binary calls its exit-time flush routine (registered viaatexit()), contains just the raw arc counter values from that run. A coverage tool likelcov/gcovneeds both — the shape from.gcno, the counts from.gcda— to reconstruct which lines executed how many times, which is exactly why deleting only one of the pair or mixing files from a different compile produces nonsense or a hard error.- Counts accumulate across runs because the exit-time writer merges, not
overwrites, into an existing
.gcdafile — each test binary invocation adds to the same file if it already exists from a prior run against the same build. That's the literal mechanism behind "stale directory reports coverage from deleted tests": the counter file remembers every run since the last--zerocountersregardless of whether the test that produced those counts still exists in your source. - Branch coverage catches what line coverage structurally cannot, because
a single source line like
if (a && b)compiles to multiple arcs (short- circuit evaluation meansbmay or may not execute) — line coverage marks the line "hit" the moment either arc is taken even once, while branch coverage requires both the true and false arc of each condition to be exercised, which is why a test suite can show 100% line coverage while never once exercising thea && !bcase.
Exercise¶
-
Reproduce section 2 exactly. Build
grade.cwith--coverage, run the three-case suite, and confirm you get 100% lines and 70% branches. If your numbers differ, check that you compiled with-O0and in two steps. -
Close the gap. Add test cases until
Taken at least oncereaches 100%. List which behaviours the new cases test that the old suite did not, and write two sentences on what that means for "100% line coverage" as a target. -
Demonstrate the gameable property. Write a test that calls
grade_forwith all five categories of input and contains no assertions at all. Measure coverage. Record the number, then explain in three sentences why it is indistinguishable from the good suite's number. -
Compare condition coverage. Line 3 is
score < 0 || score > 100. Design the minimum set of inputs that gives 100% branch coverage but leaves one sub-condition never independently decisive, and explain what MC/DC would additionally require. -
Generate an HTML report with lcov and genhtml for a multi-file project. Use
--removeto exclude system headers and your test directory, and record the before/after percentages. Explain which of the two is the honest number. -
Build the ratchet. Add a CMake
ENABLE_COVERAGEoption, then write a short shell script that fails with a non-zero exit code if branch coverage falls below a value stored in acoverage-baseline.txtfile. Verify it fails when you delete a test, and passes when you restore it.