gcov / lcov
Code coverage for C++ — gcov instruments GCC/Clang builds, lcov generates HTML reports, and llvm-cov serves the Clang-native alternative.
TL;DR
gcov measures which lines and branches your tests execute. Compile with -fprofile-arcs -ftest-coverage (or --coverage), run your tests, then use lcov + genhtml to get an HTML report. For Clang, use llvm-cov with -fprofile-instr-generate -fcoverage-mapping instead.
# Compile with coverage
g++ -std=c++20 --coverage -O0 -o myapp_tests tests.cpp src/lib.cpp
# Run tests (generates .gcda files)
./myapp_tests
# Generate HTML report
lcov --capture --directory . --output-file coverage.info
lcov --remove coverage.info '/usr/*' '*/tests/*' --output-file coverage.info
genhtml coverage.info --output-directory coverage_html
# Open
open coverage_html/index.htmlGCC coverage (gcov)
Compilation flags
# Long form
g++ -fprofile-arcs -ftest-coverage -O0 main.cpp -o myapp
# Short form (equivalent)
g++ --coverage -O0 main.cpp -o myappAlways use -O0 with coverage — optimizations merge and reorder lines, making coverage data misleading.
What gets generated
After running your tests, two types of files appear next to each .o:
.gcno— control-flow graph (created at compile time).gcda— arc counts (created at runtime, updated each test run)
# View coverage for a single file
gcov -r src/mylib.cpp
# Output: mylib.cpp.gcov with line-by-line countslcov workflow
# Install
sudo apt install lcov
# Collect coverage from all .gcda files
lcov --capture --directory . --output-file coverage.info
# Remove system headers and test files from report
lcov --remove coverage.info \
'/usr/*' \
'*/googletest/*' \
'*/tests/*' \
--output-file coverage.info
# Print summary
lcov --list coverage.info
# Generate HTML report
genhtml coverage.info \
--output-directory coverage_html \
--title "My Project" \
--legend
# Open
xdg-open coverage_html/index.htmlBranch coverage
# Enable branch coverage (line + branch)
g++ --coverage -fprofile-arcs -fbranch-probabilities -O0 -o myapp tests.cpp
# In lcov
lcov --capture --directory . --rc lcov_branch_coverage=1 --output-file coverage.info
genhtml --branch-coverage coverage.info --output-directory coverage_htmlClang coverage (llvm-cov)
Clang has its own coverage implementation that's more accurate and faster:
# Compile
clang++ -std=c++20 -fprofile-instr-generate -fcoverage-mapping -O0 -o myapp_tests tests.cpp
# Run (generates default.profraw)
LLVM_PROFILE_FILE="myapp.profraw" ./myapp_tests
# Merge profile data
llvm-profdata merge -sparse myapp.profraw -o myapp.profdata
# Show coverage summary
llvm-cov report ./myapp_tests -instr-profile=myapp.profdata
# Show per-file line coverage
llvm-cov show ./myapp_tests -instr-profile=myapp.profdata -format=html -output-dir=coverage_html
# Export as lcov format (for CI tools that expect lcov)
llvm-cov export ./myapp_tests -instr-profile=myapp.profdata -format=lcov > coverage.infoCMake integration
# Add a coverage build type
option(ENABLE_COVERAGE "Enable coverage reporting" OFF)
if(ENABLE_COVERAGE)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU")
target_compile_options(myapp_tests PRIVATE --coverage -O0)
target_link_options(myapp_tests PRIVATE --coverage)
elseif(CMAKE_CXX_COMPILER_ID MATCHES "Clang")
target_compile_options(myapp_tests PRIVATE
-fprofile-instr-generate -fcoverage-mapping -O0)
target_link_options(myapp_tests PRIVATE -fprofile-instr-generate)
endif()
endif()cmake -B build -DENABLE_COVERAGE=ON -DCMAKE_BUILD_TYPE=Debug
cmake --build build
cd build && ctest
# Then run lcov/llvm-cov as aboveCI integration (GitHub Actions)
- name: Build with coverage
run: |
cmake -B build -DENABLE_COVERAGE=ON -DCMAKE_BUILD_TYPE=Debug
cmake --build build
- name: Run tests
run: ctest --test-dir build -V
- name: Generate coverage report
run: |
lcov --capture --directory build --output-file coverage.info
lcov --remove coverage.info '/usr/*' '*/tests/*' --output-file coverage.info
- name: Upload to Codecov
uses: codecov/codecov-action@v4
with:
files: coverage.info
fail_ci_if_error: trueCoverage targets and what they mean
| Metric | Meaning | Realistic target |
|---|---|---|
| Line coverage | % of lines executed | 80-90% for libs |
| Branch coverage | % of if/else branches taken | 70-80% |
| Function coverage | % of functions called | 90%+ |
100% coverage is not the goal. Coverage shows what you didn't test — it doesn't validate that what you tested is correct. Focus on:
- Testing error paths (often the least covered)
- Testing boundary conditions
- Testing exception handling
Exclude from coverage:
- Debug/logging code
- Platform-specific fallback code unlikely to run in CI
main()and test harness code
# Exclude via annotation (GCC/Clang)
/* LCOV_EXCL_START */
void debugDumpState() { /* ... */ }
/* LCOV_EXCL_STOP */
// Single line
int x = debug ? 1 : 0; // LCOV_EXCL_LINEgcovr — Alternative to lcov
gcovr collapses the lcov + genhtml two-step into a single command. Install it with pip and you get HTML, XML, and JSON output in one invocation — no separate genhtml call required.
pip install gcovr# HTML report in one step (replaces lcov --capture + genhtml)
gcovr --root . --html --html-details -o coverage.html
# Cobertura XML for Jenkins or GitLab CI
gcovr --xml -o coverage.xml
# JSON output — useful for merging multiple coverage runs
gcovr --json -o run1.json
# Merge two JSON runs into a combined HTML report
gcovr --json run1.json --json run2.json --html -o combined.html
# Branch coverage
gcovr --branches --html --html-details -o coverage.html
# Restrict report to specific directories (ignore vendored code)
gcovr --filter src/ --filter include/ --html -o coverage.html
# Exclude paths (regex)
gcovr --exclude '.*test.*' --exclude '.*_deps.*' --html -o coverage.htmlgcovr and lcov cover the same ground but differ in ergonomics. gcovr's single-command workflow suits CI pipelines and local development; lcov's two-phase approach gives more control over report theming and merging large projects. Both accept --remove / --exclude patterns to drop third-party and system headers from the report.
| Feature | gcovr | lcov + genhtml |
|---|---|---|
| Install | pip install gcovr | apt install lcov |
| HTML report | Single command | Two steps |
| Cobertura XML | Built-in | Requires lcov_cobertura |
| Branch coverage | --branches | --rc lcov_branch_coverage=1 |
| JSON merge | --json | Not natively supported |
| Report styling | Limited | More themeable |
Coverage-Guided Fuzzing Setup
Combining --coverage with libFuzzer shows exactly which code paths the fuzzer reached. This is more informative than crash counts alone — you can see which branches remain unexplored after a fuzzing campaign and write targeted seeds to drive coverage deeper.
# Compile with both fuzzing and coverage instrumentation
clang++ -std=c++20 -fsanitize=fuzzer,address --coverage -O1 \
-o fuzz_target fuzz_target.cpp src/parser.cpp
# Run the fuzzer (generates *.profraw files alongside crashes)
LLVM_PROFILE_FILE="fuzz_%p.profraw" ./fuzz_target corpus/ -max_total_time=60
# Merge all profraw files from the fuzzing run
llvm-profdata merge -sparse fuzz_*.profraw -o fuzz.profdata
# Show which lines the fuzzer reached
llvm-cov show ./fuzz_target \
-instr-profile=fuzz.profdata \
-format=html \
-output-dir=fuzz_coverage_html
# Summary report
llvm-cov report ./fuzz_target -instr-profile=fuzz.profdataUse -O1 rather than -O0 for fuzzing — the fuzzer needs some optimization to run fast enough to be useful, even though this slightly reduces coverage granularity. After reviewing the HTML report, add corpus entries that push into uncovered branches and re-run; iterate until the interesting paths are exercised.
Differential Coverage — Before/After a Change
The most valuable question coverage can answer is: "does my new test actually execute the code it claims to test?" Diffing two coverage runs before and after adding a test gives an objective answer.
# Step 1: capture baseline (before your change)
cmake --build build
ctest --test-dir build
lcov --capture --directory build --output-file before.info
lcov --remove before.info '/usr/*' '*/tests/*' --output-file before.info
# Step 2: add your test, rebuild, and capture again
cmake --build build
ctest --test-dir build
lcov --capture --directory build --output-file after.info
lcov --remove after.info '/usr/*' '*/tests/*' --output-file after.info
# Step 3: diff the two runs
lcov --diff before.info after.info --output-file delta.info
genhtml delta.info --output-directory delta_htmlWith gcovr's JSON format, the same workflow is more portable across compilers:
# Baseline
gcovr --json -o before.json
# After adding tests
gcovr --json --json-base before.json -o after.json
gcovr --json after.json --html -o delta.htmlA green delta report confirms the test is hitting new lines. If the delta is empty, the test is exercising already-covered code — useful to know, but it means the new test adds no coverage value.
Coverage Thresholds and Enforcement
A coverage threshold that fails CI is the only way to prevent coverage from silently regressing. Without enforcement, each PR slowly erodes the number, and nobody notices until it's too late.
# Fail the build if line coverage drops below 80%
gcovr --root . --fail-under-line 80
# Fail if branch coverage drops below 70%
gcovr --root . --branches --fail-under-branch 70
# Both together
gcovr --root . \
--fail-under-line 80 \
--fail-under-branch 70 \
--html --html-details -o coverage.htmlgcovr exits with code 2 when coverage falls below threshold, which causes the CI step to fail. For lcov-based pipelines, parse the summary output with awk:
# Extract line coverage percentage and enforce threshold
COVERAGE=$(lcov --summary coverage.info 2>&1 \
| grep "lines" \
| grep -oP '\d+\.\d+(?=%)' \
| head -1)
echo "Line coverage: ${COVERAGE}%"
python3 -c "import sys; sys.exit(0 if float('${COVERAGE}') >= 80.0 else 1)"Set thresholds conservatively at first. A threshold of 80% on a codebase currently at 78% is useless — establish the baseline, then increment by 2-5% per quarter. Also, consider excluding new uncovered files from threshold calculation during a ramp-up period: it's counterproductive to block a new feature PR simply because the feature isn't fully tested yet on day one.
# GitHub Actions — fail PR if coverage regresses
- name: Check coverage thresholds
run: |
gcovr --root . \
--exclude '.*_deps.*' \
--exclude '.*test.*' \
--fail-under-line 80 \
--fail-under-branch 70 \
--xml -o coverage.xmlCoverage with Multiple Test Executables
Multiple test binaries each produce their own .gcda files — they accumulate additively on disk. Running two executables back-to-back with gcov automatically merges the counts into the same .gcda files, so you often get combined coverage for free. When you need explicit control, merge with lcov or llvm-profdata.
# Build two test executables with coverage
g++ --coverage -O0 -o unit_tests tests/unit_tests.cpp src/lib.cpp
g++ --coverage -O0 -o integration tests/integration.cpp src/lib.cpp
# Run both — .gcda files accumulate
./unit_tests
./integration
# Single lcov capture now includes both runs
lcov --capture --directory . --output-file total.info
lcov --remove total.info '/usr/*' '*/tests/*' --output-file total.info
genhtml total.info --output-directory coverage_htmlWhen you need to capture runs independently (e.g., on different machines or in parallel CI jobs) and merge them:
# Reset .gcda files between runs
lcov --zerocounters --directory .
./unit_tests
lcov --capture --directory . --output-file run1.info
lcov --zerocounters --directory .
./integration
lcov --capture --directory . --output-file run2.info
# Merge the two info files
lcov --add-tracefile run1.info \
--add-tracefile run2.info \
--output-file total.info
genhtml total.info --output-directory coverage_htmlFor llvm-cov with separate profraw files from parallel jobs:
# Each test executable writes its own profraw
LLVM_PROFILE_FILE="unit.profraw" ./unit_tests
LLVM_PROFILE_FILE="integration.profraw" ./integration
# Merge all profraw into a single profdata
llvm-profdata merge -sparse unit.profraw integration.profraw -o merged.profdata
# Report against the merged data
llvm-cov report ./unit_tests -instr-profile=merged.profdata
llvm-cov show ./unit_tests -instr-profile=merged.profdata -format=html -output-dir=coverage_htmlNote that llvm-cov needs the binary that was compiled with coverage; when you have multiple binaries, pass them all with -object:
llvm-cov report ./unit_tests \
-object ./integration \
-instr-profile=merged.profdataCoveralls and Codecov Integration
Coverage services turn raw .info files into PR comments, trend graphs, and badge URLs with minimal setup. Both Codecov and Coveralls are free for public repositories and integrate via a single GitHub Actions step.
# .github/workflows/coverage.yml
name: Coverage
on: [push, pull_request]
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: sudo apt-get install -y lcov
- name: Build with coverage
run: |
cmake -B build -DENABLE_COVERAGE=ON -DCMAKE_BUILD_TYPE=Debug
cmake --build build
- name: Run tests
run: ctest --test-dir build -V
- name: Generate coverage info
run: |
lcov --capture --directory build --output-file coverage.info
lcov --remove coverage.info '/usr/*' '*/tests/*' \
'*/build/_deps/*' --output-file coverage.info
# Option A: Codecov
- name: Upload to Codecov
uses: codecov/codecov-action@v4
with:
files: coverage.info
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }} # required for private repos
# Option B: Coveralls (comment out the Codecov step above)
- name: Upload to Coveralls
uses: coverallsapp/github-action@v2
with:
file: coverage.info
format: lcov
# token: ${{ secrets.COVERALLS_REPO_TOKEN }} # private repos onlyAdd a badge to your README once the first upload succeeds:
<!-- Codecov badge -->
[](https://codecov.io/gh/yourorg/yourrepo)
<!-- Coveralls badge -->
[](https://coveralls.io/github/yourorg/yourrepo?branch=main)Codecov and Coveralls serve the same purpose but differ in emphasis. Codecov has a richer PR diff view and supports more output formats natively (lcov, Cobertura, JaCoCo). Coveralls has a simpler setup and is the traditional choice for OSS C++ projects on GitHub. For most new projects, Codecov's free tier is the better starting point.
| Feature | Codecov | Coveralls |
|---|---|---|
| Free for OSS | Yes | Yes |
| PR diff annotations | Yes | Limited |
| Accepted formats | lcov, Cobertura, gcov, ... | lcov, gcov |
| Private repo token | Required | Required |
| Self-hosted option | Enterprise | No |
See Also
- GoogleTest — most common test runner paired with gcov for unit test coverage
- CMake — set up
ENABLE_COVERAGEbuild options and CTest integration - Catch2 — lightweight alternative to GoogleTest; same coverage workflow applies
- Fuzzing — libFuzzer and AFL++ for coverage-guided fuzz testing