Measuring C++ Code Coverage: gcov, lcov Reports, Google Test and CI Uploads
Key takeaways
From C++ code coverage measurement to CI/CD integration. gcov, lcov, Codecov tool comparison, line/branch/function coverage analysis, test quality improvement strategy. Build practical workflow integrated with Google Test.
Introduction
“I wrote tests, but how do I know if they’re sufficient?” Code Coverage provides a quantitative answer to this question. It’s a powerful tool that measures the proportion of code actually executed by tests, finding untested areas. Unit testing is important in all languages. Python’s pytest·CI, Node.js Jest, C++ Google Test, Go’s go test, Rust’s cargo test are close to standards in their respective ecosystems. Putting coverage gates in CI is covered together in C++ GitHub Actions Multi-OS Build and Node.js GitHub Actions CI/CD.
Problem Scenario
int divide(int a, int b) {
if (b == 0) { // Line 1: Condition check
return 0; // Line 2: Error handling (not executed!)
}
return a / b; // Line 3: Normal path
}
// Test code
TEST(DivideTest, NormalCase) {
EXPECT_EQ(divide(10, 2), 5); // Only test normal case
}
// Coverage measurement result:
// - Line 1: ✅ Executed (condition check)
// - Line 2: ❌ Not executed (error handling missing!)
// - Line 3: ✅ Executed (normal path)
// Line coverage: 66% (2/3)
Problem: Error case of dividing by zero is not tested! This guide covers everything from types of code coverage to measurement tools, CI/CD integration, and practical workflows.
Table of Contents
- Code Coverage Basic Concepts
- Coverage Types
- gcov Usage
- Visualization with lcov
- Google Test Integration
- Practical Workflow
- CI/CD Integration
- Tool Comparison
- Common Issues
- Best Practices
flowchart LR
A[Source Code] --> B[Compile\n--coverage]
B --> C[Run Tests]
C --> D[".gcda files\ngenerated"]
D --> E[gcov analysis]
E --> F[Coverage\nReport]
D --> G[lcov collect]
G --> H[HTML\nReport]
style F fill:#51cf66
style H fill:#4dabf7
Code Coverage Basic Concepts
Code coverage measures the proportion of code executed by tests. It is reported as a percentage (executed lines / total executable lines), collected by instrumenting the binary at compile time and recording which lines, branches, and functions actually ran during a test suite. It answers “did this code run at all?” — not “is this code correct?” — which is why coverage is a floor for test quality, not a ceiling.
Coverage Types
Different coverage metrics catch different gaps — a high line-coverage number can still hide untested branches.
Line Coverage
The simplest and most common metric: what fraction of executable source lines ran at least once.
int classify(int score) {
if (score >= 90) return 1; // line covered if any test hits 90+
return 0; // line covered if any test hits <90
}
Branch Coverage
Stricter than line coverage — a single if line can be “covered” by line coverage while only ever being tested in one direction (always true, or always false).
int classify(int score) {
if (score >= 90) return 1; // ← both true AND false paths must run
return 0; // for 100% branch coverage on this line
}
// TEST(Classify, High) { EXPECT_EQ(classify(95), 1); } // true branch
// TEST(Classify, Low) { EXPECT_EQ(classify(50), 0); } // false branch
Function Coverage
Tracks whether each function was called at all — the coarsest metric, useful for quickly spotting entirely untested functions (e.g. error-handling helpers, rarely used utility functions).
Condition Coverage
For compound conditions (a && b, a || b), condition coverage checks that each sub-expression was independently evaluated both true and false — branch coverage alone can be satisfied without ever proving b matters when a is true.
bool isValidUser(bool hasAccount, bool isActive) {
return hasAccount && isActive; // condition coverage needs 4 cases,
// not just 2 (branch coverage)
}
Which to Target
| Metric | Strictness | Typical target |
|---|---|---|
| Function coverage | Loosest | 95%+ (easy to hit, flags dead code) |
| Line coverage | Moderate | 70–90% |
| Branch coverage | Strict | 60–80% (harder to reach 100% meaningfully) |
| Condition coverage | Strictest | Reserved for safety-critical logic |
gcov Usage (GCC Built-in Tool)
gcov is a code coverage analysis tool built into GCC.
Basic Workflow
# 1. Compile with coverage option
g++ --coverage program.cpp -o program
# Or
g++ -fprofile-arcs -ftest-coverage program.cpp -o program
# 2. Run program (run tests)
./program
# 3. Check coverage data files generated
ls *.gcda *.gcno
# program.gcno: Generated at compile time (graph info)
# program.gcda: Generated at runtime (execution count)
# 4. Analyze with gcov
gcov program.cpp
# 5. Check result file
cat program.cpp.gcov
Note: Using with optimization (-O2 or higher) can misalign line mapping, so often have a dedicated build type for coverage.
Practical Example: Calculator Program
// calculator.cpp
#include <iostream>
int add(int a, int b) {
return a + b;
}
int subtract(int a, int b) {
return a - b;
}
int multiply(int a, int b) {
return a * b;
}
int divide(int a, int b) {
if (b == 0) {
std::cerr << "Error: Division by zero\n";
return 0;
}
return a / b;
}
int main() {
std::cout << "10 + 5 = " << add(10, 5) << "\n";
std::cout << "10 - 5 = " << subtract(10, 5) << "\n";
std::cout << "10 * 5 = " << multiply(10, 5) << "\n";
// divide function not called!
return 0;
}
# Compile and run
$ g++ --coverage calculator.cpp -o calculator
$ ./calculator
10 + 5 = 15
10 - 5 = 5
10 * 5 = 50
# Coverage analysis
$ gcov calculator.cpp
File 'calculator.cpp'
Lines executed:73.33% of 15
Creating 'calculator.cpp.gcov'
# Check detailed report
$ cat calculator.cpp.gcov
-: 0:Source:calculator.cpp
-: 1:#include <iostream>
-: 2:
1: 3:int add(int a, int b) {
1: 4: return a + b;
-: 5:}
-: 6:
1: 7:int subtract(int a, int b) {
1: 8: return a - b;
-: 9:}
-: 10:
1: 11:int multiply(int a, int b) {
1: 12: return a * b;
-: 13:}
-: 14:
#####: 15:int divide(int a, int b) { // ##### = not executed!
#####: 16: if (b == 0) {
#####: 17: std::cerr << "Error: Division by zero\n";
#####: 18: return 0;
-: 19: }
#####: 20: return a / b;
-: 21:}
-: 22:
1: 23:int main() {
1: 24: std::cout << "10 + 5 = " << add(10, 5) << "\n";
1: 25: std::cout << "10 - 5 = " << subtract(10, 5) << "\n";
1: 26: std::cout << "10 * 5 = " << multiply(10, 5) << "\n";
1: 27: return 0;
-: 28:}
# Interpretation:
# - "1:": Executed once
# - "#####:": Not executed (uncovered)
# - "-:": Non-executable line (comments, declarations, etc.)
gcov Advanced Options
# Include branch coverage
gcov -b calculator.cpp
# Output:
# Function 'divide'
# Lines executed:0.00% of 5
# Branches executed:0.00% of 2
# Taken at least once:0.00% of 2
# Function-level coverage
gcov -f calculator.cpp
# Output:
# Function 'add'
# Lines executed:100.00% of 1
#
# Function 'subtract'
# Lines executed:100.00% of 1
#
# Function 'multiply'
# Lines executed:100.00% of 1
#
# Function 'divide'
# Lines executed:0.00% of 5
# Show only unexecuted lines
gcov -u calculator.cpp
# All options combined
gcov -b -f -u calculator.cpp
CMake Integration
# CMakeLists.txt
cmake_minimum_required(VERSION 3.15)
project(MyProject)
set(CMAKE_CXX_STANDARD 17)
# Add coverage build type
if(CMAKE_BUILD_TYPE STREQUAL "Coverage")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} --coverage")
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} --coverage")
endif()
add_executable(myapp src/main.cpp src/math.cpp)
# Add coverage target
if(CMAKE_BUILD_TYPE STREQUAL "Coverage")
add_custom_target(coverage
COMMAND ${CMAKE_CURRENT_BINARY_DIR}/myapp
COMMAND gcov ${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}
COMMENT "Generating coverage report"
)
endif()
# Build and generate coverage
mkdir build && cd build
cmake -DCMAKE_BUILD_TYPE=Coverage ..
make
make coverage
Visualization with lcov (HTML Report)
lcov is a tool that collects gcov data and generates beautiful HTML reports.
Installation
# Ubuntu/Debian
sudo apt install lcov
# macOS
brew install lcov
# Arch Linux
sudo pacman -S lcov
Basic Usage
# 1. Compile with coverage option
g++ --coverage src/*.cpp -o myapp -lgtest -lgtest_main
# 2. Run tests
./myapp
# 3. Collect coverage data
lcov --capture --directory . --output-file coverage.info
# 4. Generate HTML report
genhtml coverage.info --output-directory coverage_html
# 5. View in browser
open coverage_html/index.html # macOS
xdg-open coverage_html/index.html # Linux
start coverage_html/index.html # Windows (Git Bash)
Google Test Integration
Complete example using Google Test with coverage.
CMakeLists.txt
cmake_minimum_required(VERSION 3.15)
project(StringUtilsProject)
set(CMAKE_CXX_STANDARD 17)
# Download Google Test
include(FetchContent)
FetchContent_Declare(
googletest
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG v1.14.0
)
FetchContent_MakeAvailable(googletest)
# Source library
add_library(string_utils src/string_utils.cpp)
target_include_directories(string_utils PUBLIC src)
# Test executable
add_executable(string_utils_test test/string_utils_test.cpp)
target_link_libraries(string_utils_test string_utils gtest gtest_main)
# Coverage settings
option(ENABLE_COVERAGE "Enable coverage reporting" OFF)
if(ENABLE_COVERAGE)
target_compile_options(string_utils PRIVATE --coverage)
target_link_options(string_utils PRIVATE --coverage)
target_compile_options(string_utils_test PRIVATE --coverage)
target_link_options(string_utils_test PRIVATE --coverage)
# Coverage target
find_program(LCOV lcov REQUIRED)
find_program(GENHTML genhtml REQUIRED)
add_custom_target(coverage
COMMAND ${LCOV} --directory . --zerocounters
COMMAND $<TARGET_FILE:string_utils_test>
COMMAND ${LCOV} --capture --directory . --output-file coverage.info
COMMAND ${LCOV} --remove coverage.info '/usr/*' '*/test/*' '*/googletest/*'
--output-file coverage_filtered.info
COMMAND ${GENHTML} coverage_filtered.info --output-directory coverage_html
COMMAND ${LCOV} --summary coverage_filtered.info
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
COMMENT "Generating coverage report"
DEPENDS string_utils_test
)
endif()
# CTest integration
enable_testing()
add_test(NAME string_utils_test COMMAND string_utils_test)
Build and Run
# Build with coverage enabled
mkdir build && cd build
cmake -DENABLE_COVERAGE=ON ..
make
# Run tests
./string_utils_test
# Output:
# [==========] Running 4 tests from 1 test suite.
# [----------] Global test environment set-up.
# [----------] 4 tests from StringUtilsTest
# [ RUN ] StringUtilsTest.TrimSpaces
# [ OK ] StringUtilsTest.TrimSpaces (0 ms)
# [ RUN ] StringUtilsTest.SplitString
# [ OK ] StringUtilsTest.SplitString (0 ms)
# [ RUN ] StringUtilsTest.StartsWith
# [ OK ] StringUtilsTest.StartsWith (0 ms)
# [ RUN ] StringUtilsTest.EndsWith
# [ OK ] StringUtilsTest.EndsWith (0 ms)
# [----------] 4 tests from StringUtilsTest (0 ms total)
# [==========] 4 tests from 1 test suite ran. (0 ms total)
# [ PASSED ] 4 tests.
# Generate coverage report
make coverage
# Output:
# Generating coverage report
# Overall coverage rate:
# lines......: 100.0% (24 of 24 lines)
# functions..: 100.0% (4 of 4 functions)
# View HTML report
open coverage_html/index.html
Practical Workflow
Daily Development Workflow
# 1. Develop new feature
vim src/new_feature.cpp
# 2. Write tests
vim test/new_feature_test.cpp
# 3. Build and test
mkdir -p build && cd build
cmake -DENABLE_COVERAGE=ON ..
make
./test_runner
# 4. Check coverage
make coverage
# 5. Check uncovered parts
open coverage_html/index.html
# 6. Write additional tests (uncovered parts)
vim test/new_feature_test.cpp
# 7. Test and check coverage again
make && make coverage
# 8. Commit when goal achieved
git add .
git commit -m "Add new feature with 90% coverage"
CI/CD Integration
GitHub Actions Workflow
Running coverage on every PR and uploading the report gives reviewers a visible signal without anyone needing to run lcov locally.
# .github/workflows/coverage.yml
name: Coverage
on: [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: |
mkdir build && cd build
cmake -DENABLE_COVERAGE=ON ..
make
- name: Run tests
run: cd build && ./string_utils_test
- name: Generate coverage report
run: |
cd build
lcov --capture --directory . --output-file coverage.info
lcov --remove coverage.info '/usr/*' '*/test/*' '*/googletest/*' \
--output-file coverage_filtered.info
- name: Upload to Codecov
uses: codecov/codecov-action@v4
with:
files: build/coverage_filtered.info
Failing the Build Below a Threshold
Gating merges on a coverage floor prevents a slow erosion of test quality — lcov --summary combined with a small parsing step is enough to enforce it without a paid service.
#!/bin/bash
# check_coverage.sh
THRESHOLD=70
COVERAGE=$(lcov --summary coverage_filtered.info 2>&1 | grep "lines" | grep -oP '\d+\.\d+(?=%)')
if (( $(echo "$COVERAGE < $THRESHOLD" | bc -l) )); then
echo "❌ Coverage $COVERAGE% is below threshold $THRESHOLD%"
exit 1
fi
echo "✅ Coverage $COVERAGE% meets threshold $THRESHOLD%"
Coverage Diff on Pull Requests
Codecov and similar services can annotate a PR with only the coverage delta introduced by that PR’s changed lines — more actionable than a single project-wide percentage, since it flags exactly the new code that lacks tests.
- uses: codecov/codecov-action@v4
with:
files: build/coverage_filtered.info
fail_ci_if_error: true
# codecov.yml can set: project target auto, patch target 80%
Tool Comparison
| Tool | Type | Output | Strengths | Best for |
|---|---|---|---|---|
| gcov | GCC built-in | Text (.gcov files) | No extra install, always available with GCC | Quick local checks |
| lcov | gcov frontend | HTML | Visual, drill-down by file/function | Local development, PR review |
| llvm-cov | Clang built-in | Text/HTML/JSON | Works with Clang’s source-based coverage, faster than gcov on large projects | Clang-based toolchains |
| Codecov | SaaS | Web dashboard, PR comments | Historical trends, PR diffs, badges | Team/CI visibility |
| Coveralls | SaaS | Web dashboard, PR comments | Similar to Codecov, simpler free tier | Small/open-source projects |
| OpenCppCoverage | Standalone | HTML/Cobertura XML | Works with MSVC, no --coverage flag needed | Windows/MSVC projects |
gcov vs llvm-cov
If the project builds with Clang, llvm-cov (source-based coverage via -fprofile-instr-generate -fcoverage-mapping) is generally preferred over piping through gcov-compatible output — it is faster and gives more accurate branch information for Clang-compiled code.
clang++ -fprofile-instr-generate -fcoverage-mapping test.cpp -o test
LLVM_PROFILE_FILE="test.profraw" ./test
llvm-profdata merge -sparse test.profraw -o test.profdata
llvm-cov show ./test -instr-profile=test.profdata
Common Issues
Coverage Shows 0% Despite Running Tests
Usually means the .gcda files were generated in a different directory than where lcov/gcov is looking, or a stale build wasn’t recompiled with --coverage.
# Clean stale coverage data before re-running
find . -name "*.gcda" -delete
lcov --directory . --zerocounters
make && ./test_runner
lcov --capture --directory . --output-file coverage.info
Coverage Numbers Change Between Runs
Coverage should be deterministic for a fixed test suite — non-determinism usually means parallel test execution is racing on the same .gcda files, or leftover data from a previous run wasn’t cleared.
# Run tests serially when measuring coverage, or isolate gcda output per-process
GCOV_PREFIX=/tmp/cov-$$ GCOV_PREFIX_STRIP=99 ./test_runner
Template-Heavy Code Shows Misleading Coverage
Each template instantiation can report separately, so a templated function tested with only int may show as “covered” while the std::string instantiation used elsewhere in production is never exercised — coverage tools generally cannot distinguish between instantiations in the summary percentage.
--remove Filters Nothing / Third-Party Code Pollutes the Report
lcov --remove patterns must match the absolute paths stored in coverage.info, which can differ from what looks intuitive when the build uses out-of-tree or symlinked source directories.
# Inspect actual stored paths first
lcov --list coverage.info | head -5
# Then match the real prefix, not an assumed one
lcov --remove coverage.info '/usr/*' "$(pwd)/build/_deps/*" --output-file coverage_filtered.info
Coverage Build Is Much Slower or Larger
--coverage instrumentation adds counters to every basic block, which increases both binary size and runtime — expected, and not a sign of misconfiguration. Keep a separate Coverage CMake build type (as shown earlier) so release builds stay uninstrumented.
What a coverage number does not tell you
Coverage measures which lines and branches ran during the tests, not whether the tests checked anything. A test that calls a function and asserts nothing raises line coverage as much as a careful one, so a high percentage can sit on top of weak tests. Read the report for what it does show well: code that no test reaches at all, and branches (error paths, else sides) that never ran. Branch coverage is more informative than line coverage for exactly this reason, because a line with a condition counts as covered after one outcome.
Two measurement mistakes make gcov numbers misleading. Building with optimization on lets the compiler merge, inline and reorder code, so line attribution drifts; measure coverage in a separate -O0 --coverage build. And .gcda files accumulate counts across runs, so a report generated after several test runs, or after a partial rebuild, mixes old and new data; reset the counters (lcov --zerocounters or deleting the .gcda files) before each measured run.
Next Steps
Related Articles
- C++ Conan Basics — Install, conanfile, Profiles, CMake
- Node.js Testing Guide
- CMake Tutorial for C++: CMakeLists.txt and Targets
- GoogleTest and gMock in Practice
Frequently Asked Questions (FAQ)
Q. Why does lcov report high line coverage but show no branch coverage?
A. Line coverage only tells you a line ran; a line like if (a && b) counts as covered even if b was never false. lcov collects branch data only when you enable it, with --rc lcov_branch_coverage=1 in lcov 1.x or --rc branch_coverage=1 in lcov 2.x, and genhtml needs the same option to display it. Expect branch numbers to be noisier than line numbers, because the compiler adds branches for exceptions and inlined code.