C++ Code Coverage Complete Guide | gcov· lcov
이 글의 핵심
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.
Summary
Key Points
- Code Coverage: Measures proportion of code executed by tests
- gcov: GCC’s built-in coverage tool
- lcov: Frontend tool generating HTML reports
- Google Test: C++ testing framework
- CI/CD: Automate coverage measurement and reporting
Best Practices
- Set realistic coverage targets (70-90%)
- Focus on critical business logic
- Don’t chase 100% coverage blindly
- Use coverage as a guide, not a goal
- Integrate into CI/CD pipeline
- Review coverage reports regularly
Next Steps
Related Articles
Frequently Asked Questions (FAQ)
Q. When would I use this in practice?
A. From C++ code coverage measurement to CI/CD integration.
Q. What should I read before this?
A. Follow the previous article or related articles links at the bottom of each post to learn in sequence. See the C++ series index for the full picture.
Q. Where can I study this more deeply?
A. Check cppreference and the relevant library’s official documentation. The reference links at the end of the article are also worth using.
Related Articles (Internal Links)
Other articles related to this topic.
- C++ Conan 완벽 가이드 | 현대적인 C++ 패키지 관리
- C++ CLion 완벽 설정 가이드 | CMake·디버거·코드 분석·vcpkg·원격 개발 [#53-1]
- C++ CMake | ‘빌드 시스템’ 초보자 가이드
- C++ Google Test | gtest 설치부터 TEST·EXPECT_EQ
- Node.js 테스트 | Jest, Mocha, Supertest 완벽 가이드
- Python 환경 설정 | Windows/Mac에서 Python 설치하고 시작하기
- [Go 2주 완성 #07] Day 12~13: 의존성 관리와 테스팅 - CMake보다 쉬운 세상
- Rust 테스팅 | 단위 테스트, 통합 테스트, 벤치마크
- C++ CI/CD 파이프라인: GitHub Actions를 이용한 멀티 OS 자동 빌드·테스트 가이드
- GitHub Actions로 Node.js CI/CD 파이프라인 만들기 | 테스트·빌드·Docker·배포
Keywords Covered in This Article (Related Search Terms)
This article covers C++, code-coverage, testing, gcov, lcov, quality, CI/CD.