Skip to content

Code Coverage

Code coverage is a metric used to calculate the percentage of source code executed during the Testing phase. In high-reliability C++ systems, coverage analysis is essential for identifying Untested logical branches and dead code.

Unlike dynamic analysis (Sanitizers) which checks for correctness, coverage checking checks for Completeness.

Coverage tools work by instrumenting the binary. During compilation, the compiler inserts hidden Counter instructions at the entry points of every Basic Block (a linear sequence of code with One entry and one exit).

When the program runs, these counters increment. Upon process termination, the runtime writes the Counter data to raw profile files on the disk, which are then mapped back to the source code by Analysis tools.

:::caution Performance Overhead Instrumentation significantly increases binary size and execution Time. Coverage builds should never be used for performance benchmarking or production release Artifacts. :::

There are two primary coverage implementations in the modern C++ landscape:

  1. Gcov (GCC): The traditional approach. Generates .gcno (notes) files during build and .gcda (data) files during execution.
  2. Source-Based Coverage (Clang/LLVM): The modern approach. Uses the LLVM profile runtime. It is generally faster and handles C++ templates and inlined functions with greater precision.

This workflow applies to GCC on Linux and MinGW-w64 on Windows.

  • gcov: Included with GCC.
  • lcov: A graphical frontend for gcov.
  • genhtml: Generates HTML reports from lcov data.

To instrument the binary, pass --coverage to both the compiler and the linker. This is a shorthand For -fprofile-arcs -ftest-coverage and linking libgcov.

Terminal window
g++ -std=c++23 --coverage -g -O0 main.cpp -o app

Run the application (or test suite). This generates .gcda files alongside the object files.

Terminal window
./app

Use lcov to capture the data and genhtml to visualize it.

Terminal window
# 1. Capture coverage data
lcov --capture --directory . --output-file coverage.info
# 2. Filter out system headers and external libraries (optional but recommended)
lcov --remove coverage.info '/usr/*' '*/vcpkg/*' --output-file coverage_filtered.info
# 3. Generate HTML report
genhtml coverage_filtered.info --output-directory out

Open out/index.html in a browser to view line-by-line coverage.

This workflow applies to Clang on Linux, macOS, and MSYS2.

  • llvm-profdata: Merges raw profile data.
  • llvm-cov: Maps profiles to source code.

Clang requires specific flags to enable the source-based profiling runtime.

Terminal window
clang++ -std=c++23 -fprofile-instr-generate -fcoverage-mapping -g -O0 main.cpp -o app

Run the application. By default, LLVM writes to default.profraw.

Terminal window
LLVM_PROFILE_FILE="app-%p.profraw" ./app

Note: %p expands to the process ID, useful for multi-process applications.

Merge the raw profiles into an indexed format optimized for viewing.

Terminal window
llvm-profdata merge -sparse app-*.profraw -o app.profdata

Use llvm-cov to display the results. Note that llvm-cov requires the executable to map the Counters back to the source.

Terminal window
# Console Summary
llvm-cov report ./app -instr-profile=app.profdata
# HTML Export
llvm-cov show ./app -instr-profile=app.profdata -format=html -output-dir=coverage_report

To maintain a clean build architecture, coverage logic should be encapsulated within the build System but guarded by a configuration option. We do not want coverage flags leaking into Release or Standard Debug builds.

Add the following logic to your root CMakeLists.txt or a dedicated module.

option(ENABLE_COVERAGE "Enable code coverage instrumentation" OFF)
if(ENABLE_COVERAGE)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU")
# GCC Configuration
add_compile_options(--coverage -g -O0)
add_link_options(--coverage)
elseif(CMAKE_CXX_COMPILER_ID MATCHES "Clang")
# Clang Configuration
add_compile_options(-fprofile-instr-generate -fcoverage-mapping -g -O0)
add_link_options(-fprofile-instr-generate -fcoverage-mapping)
else()
message(FATAL_ERROR "Code coverage not supported for this compiler")
endif()
endif()

Instead of manually typing lcov or llvm-cov commands, define a custom CMake target to automate The generation.

if(ENABLE_COVERAGE AND CMAKE_CXX_COMPILER_ID MATCHES "Clang")
find_program(LLVM_PROFDATA llvm-profdata REQUIRED)
find_program(LLVM_COV llvm-cov REQUIRED)
add_custom_target(coverage-report
COMMAND ${LLVM_PROFDATA} merge -sparse default.profraw -o coverage.profdata
COMMAND ${LLVM_COV} show $<TARGET_FILE:UnitTests>
-instr-profile=coverage.profdata
-format=html
-output-dir=coverage_html
COMMAND ${LLVM_COV} report $<TARGET_FILE:UnitTests>
-instr-profile=coverage.profdata
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
COMMENT "Generating HTML coverage report..."
)
endif()
  1. Configure: cmake -S . -B build -DENABLE_COVERAGE=ON
  2. Build: cmake --build build
  3. Run Tests: cd build && ctest
  4. Generate Report: cmake --build build --target coverage-report

Continuous Integration (CI) Best Practices

Section titled “Continuous Integration (CI) Best Practices”

In a CI environment (GitHub Actions, GitLab CI), the goal is to fail the build if coverage drops Below a threshold or to upload reports to visualization services.

Do not commit coverage artifacts. Instead, configure the CI pipeline to upload the coverage_html Directory as a build artifact for manual inspection.

Services like Codecov ingest the raw reports (XML or LCOV format) and provide pull-request Integration (e.g., “This PR decreases coverage by 2%”).

To support these services, llvm-cov can export to LCOV format:

Terminal window
llvm-cov export ./app -instr-profile=app.profdata -format=lcov > coverage.info

Coverage Metrics: Line vs. Branch vs. Function

Section titled “Coverage Metrics: Line vs. Branch vs. Function”

Coverage tools report multiple metrics, each measuring a different aspect of test completeness.

The simplest metric: the percentage of source lines that execute at least once during testing.

Lines: 847/1200 (70.6%)

Limitation: A line containing a complex ternary expression counts as “covered” if any branch Executes. Line coverage alone can give a false sense of completeness.

Measures the percentage of conditional branches (if/else, switch, ternary) where both the true And false paths have been taken.

Branches: 234/300 (78.0%)

Example:

int classify(int x) {
if (x > 0) { // Branch 1: true
return 1;
} else if (x < 0) { // Branch 2: true (only if Branch 1 was false)
return -1;
} else {
return 0; // Branch 1: false AND Branch 2: false
}
}

If tests only call classify(1) and classify(-1)Line coverage is 100% (all lines execute), but Branch coverage is 83% (the return 0 path is never taken).

Measures the percentage of functions that have been called at least once.

Functions: 45/50 (90.0%)

Limitation: Calling a function once only proves the entry point is reached. It does not verify Internal logic.

MetricGranularityFalse Confidence RiskCost to Achieve High %
LineCoarseHighLow
BranchMediumLowMedium
FunctionCoarse (per-function)HighLow
ConditionFine (per-subexpression)Very LowHigh

Industry standard for critical systems: Branch coverage > 80%, line coverage > 90%. For Safety-critical (automotive, medical), branch coverage > 95% is often required (per ISO 26262 / IEC 62304).

Understanding the gcov file format helps diagnose coverage issues.

Generated at compile time by -ftest-coverage. Contains:

  • The source file name and line number mapping.
  • Basic block structure (the CFG - control flow graph).
  • Edge counts between basic blocks (initialized to zero).
  • Location of counters in the generated object file.

Generated at runtime when the instrumented program exits. Contains:

  • The actual execution counts for each basic block.
  • Arc transitions (how many times each branch was taken).
  • Summary statistics (total runs, total counts).
Compile: main.cpp → main.o + main.gcno
Link: main.o → app
Run: ./app → main.gcda (written at exit)
Analyze: gcov main.gcno main.gcda → main.cpp.gcov
  • Stale .gcda files: If you recompile without running the program, old .gcda files from a previous run remain. Always run the program after recompilation, or delete .gcda files before analysis.
  • Missing .gcda files: If the program crashes (segfault) without calling atexit() handlers, .gcda files may not be written. Use __gcov_flush() to force-write coverage data at specific checkpoints.
#include <gcov.h> // GCC-specific
void critical_section() {
// Force flush coverage data before risky operation
__gcov_flush();
perform_risky_operation();
}

Not all code should contribute to coverage metrics. Test harnesses, generated code, and third-party Templates inflate coverage numbers without providing value.

Terminal window
lcov --capture --directory . --output-file coverage.info \
--exclude '*/test/*' \
--exclude '*/generated/*' \
--exclude '*/third_party/*' \
--exclude '/usr/*'

GCC and Clang support the __attribute__((no_sanitize("coverage"))) attribute (Clang) or pragmas to Exclude specific functions:

// GCC: exclude a function from coverage
void debug_only_function() __attribute__((no_instrument_function));
void debug_only_function() {
// This function will not appear in coverage reports
}
// Clang: exclude using -fprofile-exclude
// clang++ -fprofile-instr-generate -fcoverage-mapping \
// -fprofile-exclude='*/test/*.cpp'

Lcov respects exclusion markers in source comments:

// LCOV_EXCL_START
void internal_debug_dump() {
// This entire function is excluded from coverage
std::cerr << "Debug dump...\n";
}
// LCOV_EXCL_STOP
int main() {
// LCOV_EXCL_LINE — only this line is excluded
return 0;
}
Terminal window
# Full pipeline
lcov --capture --directory . --output-file coverage.info
lcov --remove coverage.info '/usr/*' '*/test/*' --output-file coverage_filtered.info
genhtml coverage_filtered.info \
--output-directory coverage_html \
--legend \
--show-details \
--branch-coverage \
--title "MyProject Coverage Report"

Output structure:

coverage_html/
├── index.html # Summary page with overall metrics
├── index.html.sort.html # Sorted by coverage percentage
├── main.cpp.gcov.html # Per-file detailed report
└── ...
Terminal window
llvm-cov show ./app \
-instr-profile=app.profdata \
-format=html \
-output-dir=coverage_html \
-title "MyProject Coverage" \
-show-line-counts-or-regions \
-show-branches=percent

Clang’s HTML report is generally considered more readable than lcov’s, with color-coded regions and Branch coverage percentages inline.

Coverage Report Integration (Codecov Example)

Section titled “Coverage Report Integration (Codecov Example)”

For CI integration with Codecov:

# GitHub Actions
- name: Generate coverage report
run: |
cmake --build build
cd build && ctest
llvm-profdata merge -sparse default.profraw -o coverage.profdata
llvm-cov export ./UnitTests \
-instr-profile=coverage.profdata \
-format=lcov > coverage.info
- name: Upload to Codecov
uses: codecov/codecov-action@v4
with:
files: build/coverage.info
fail_ci_if_error: true
flags: unittests

Enforcing minimum coverage thresholds prevents regressions.

CTest supports coverage thresholds since CMake 3.28:

if(ENABLE_COVERAGE)
# GCC coverage setup
add_compile_options(--coverage -g -O0)
add_link_options(--coverage)
# Define coverage target
add_custom_target(coverage
COMMAND lcov --capture --directory . --output-file coverage.info
COMMAND lcov --remove coverage.info '/usr/*' '*/test/*' --output-file coverage_filtered.info
COMMAND genhtml coverage_filtered.info --output-directory coverage_html
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
)
# Enforce minimum coverage (CMake 3.28+)
add_custom_target(coverage-check
COMMAND ${CMAKE_COMMAND}
-DCOVERAGE_FILE=${CMAKE_BINARY_DIR}/coverage_filtered.info
-DMIN_LINE_COVERAGE=80
-DMIN_BRANCH_COVERAGE=70
-P ${CMAKE_SOURCE_DIR}/cmake/check_coverage.cmake
DEPENDS coverage
)
endif()

cmake/check_coverage.cmake:

file(READ "${COVERAGE_FILE}" COVERAGE_DATA)
string(REGEX MATCH "lines...: ([0-9.]+)%" _line_match "${COVERAGE_DATA}")
string(REGEX MATCH "functions...: ([0-9.]+)%" _func_match "${COVERAGE_DATA}")
set(LINE_COV ${CMAKE_MATCH_1})
set(FUNC_COV ${CMAKE_MATCH_1})
message(STATUS "Line coverage: ${LINE_COV}% (minimum: ${MIN_LINE_COVERAGE}%)")
message(STATUS "Function coverage: ${FUNC_COV}%")
if(LINE_COV LESS MIN_LINE_COVERAGE)
message(FATAL_ERROR "Line coverage ${LINE_COV}% is below threshold ${MIN_LINE_COVERAGE}%")
endif()
- name: Build with coverage
run: cmake -S . -B build -DENABLE_COVERAGE=ON && cmake --build build
- name: Run tests
run: cd build && ctest --output-on-failure
- name: Check coverage threshold
run: cmake --build build --target coverage-check
  1. Running coverage builds in production: Coverage instrumentation adds 20-50% overhead. Never deploy instrumented binaries to production. Use separate build configurations for coverage and release.
  2. Stale .gcda files from previous runs: Always clean the build directory or delete .gcda files before running tests. Otherwise, coverage data from a previous run may be merged with the current run, producing inaccurate results.
  3. Optimized builds hiding dead code: Compiling with -O2 or higher may eliminate unreachable code, causing it to appear as “not covered” rather than “dead code.” Always compile with -O0 for coverage builds.
  4. Coverage of third-party code: Including system headers or vcpkg libraries in coverage reports inflates numbers. Always filter them out with lcov --remove or llvm-cov exclude paths.
  5. Multi-process coverage merge: When running tests in parallel (e.g., ctest -j8), each process writes to the same .gcda file. Use LLVM_PROFILE_FILE="app-%p.profraw" (Clang) or set GCOV_PREFIX (GCC) to separate output per process.

This topic covers the essential concepts and techniques related to code coverage, including key principles and practical applications.

Key concepts include:

  • core concepts and definitions
  • key principles and frameworks
  • practical applications
  • common techniques and methods
  • evaluation and critical analysis

A thorough understanding of these concepts, combined with regular practice and review, is essential for mastery of this topic.

Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.