C++ 코드 커버리지 측정: gcov·lcov 리포트, Google Test 연동, CI에서 Codecov 올리기

이 글의 핵심

커버리지 숫자만 높이면 테스트가 좋아진다는 착각에서 벗어나려면 유형별 차이를 알아야 합니다. .gcda 파일이 생기지 않거나 이전 실행 데이터가 누적되는 문제, 인라인 함수와 템플릿이 커버리지에서 빠지는 이유처럼 실제로 자주 막히는 지점을 해결법과 함께 짚습니다.

들어가며

“테스트는 작성했는데, 충분한지 어떻게 알 수 있을까요?” 코드 커버리지(Code Coverage)는 이 질문에 대한 정량적 답을 제공합니다. 테스트가 실제로 실행한 코드의 비율을 측정하여, 테스트되지 않은 영역을 찾아내는 강력한 도구입니다. 단위 테스트는 모든 언어에서 중요합니다. Python에서 pytest·CI, Node.js의 Jest, C++의 Google Test, Go의 go test, Rust의 cargo test는 각각의 생태계에서 표준에 가깝습니다. 커버리지 게이트를 CI에 넣는 흐름은 C++ GitHub Actions 멀티 OS 빌드와 Node.js GitHub Actions CI/CD에서 함께 다룹니다.

문제 시나리오

int divide(int a, int b) {
    if (b == 0) {        // 라인 1: 조건 체크
        return 0;        // 라인 2: 에러 처리 (미실행!)
    }
    return a / b;        // 라인 3: 정상 경로
}
// 테스트 코드
TEST(DivideTest, NormalCase) {
    EXPECT_EQ(divide(10, 2), 5);  // 정상 케이스만 테스트
}
// 커버리지 측정 결과:
// - 라인 1: ✅ 실행됨 (조건 체크)
// - 라인 2: ❌ 실행 안 됨 (에러 처리 누락!)
// - 라인 3: ✅ 실행됨 (정상 경로)
// 라인 커버리지: 66% (2/3)

정상 케이스만 테스트했기 때문에 0으로 나누는 에러 처리 경로가 한 번도 실행되지 않았고, 커버리지 리포트는 바로 그 줄을 미실행으로 표시해 줍니다. 이 글에서는 커버리지의 종류부터 gcov·lcov 사용법, Google Test·CMake 연동, CI 통합까지 다룹니다.

코드 커버리지 기본 개념

코드 커버리지는 테스트가 실행한 코드의 비율을 측정합니다. 무엇을 단위로 세느냐에 따라 라인·브랜치·함수·조건·경로 커버리지로 나뉘며, 각 유형의 차이는 뒤의 커버리지 유형 절에서 예제와 함께 설명합니다. 먼저 GCC에서 실제로 숫자를 얻는 방법부터 봅니다.

gcov 사용법 (GCC 기본 도구)

gcov는 GCC에 내장된 코드 커버리지 분석 도구입니다.

gcov의 내부 동작 원리:

gcov 커버리지 측정 메커니즘:

1. 컴파일 단계 (--coverage 플래그):
   ┌─────────────────────────────────────┐
   │ g++ --coverage program.cpp          │
   │                                     │
   │ 내부 동작:                           │
   │ 1. -fprofile-arcs 활성화            │
   │    → CFG 간선(arc)마다              │
   │      카운터 증가 코드 삽입           │
   │                                     │
   │ 2. -ftest-coverage 활성화           │
   │    → .gcno 파일 생성                │
   │      (그래프 구조, 라인 매핑)        │
   │                                     │
   │ 3. libgcov.a 링크                   │
   │    → 런타임 데이터 수집 라이브러리   │
   └─────────────────────────────────────┘

2. 실행 단계 (./program):
   ┌─────────────────────────────────────┐
   │ 프로그램 시작                        │
   │ ↓                                   │
   │ 카운터는 오브젝트 파일의 전역 배열  │
   │ (.gcno는 런타임에 읽지 않음)         │
   │ ↓                                   │
   │ 코드 실행                            │
   │ - 계측된 간선을 지날 때마다         │
   │   해당 카운터 증가                   │
   │ ↓                                   │
   │ 프로그램 종료 (exit, main return)    │
   │ - 종료 처리기(atexit/소멸자) 실행    │
   │ - 기존 .gcda가 있으면 읽어서 합산 후 │
   │   .gcda 파일 쓰기                    │
   └─────────────────────────────────────┘

3. 분석 단계 (gcov program.cpp):
   ┌─────────────────────────────────────┐
   │ gcov 실행                            │
   │ ↓                                   │
   │ .gcno 파일 읽기                     │
   │ - 그래프 구조 (basic block 연결)    │
   │ - 라인 번호 매핑                     │
   │ ↓                                   │
   │ .gcda 파일 읽기                     │
   │ - 실행 카운트 (각 basic block)      │
   │ ↓                                   │
   │ 라인별 실행 횟수 계산                │
   │ - basic block 카운트 → 라인 카운트  │
   │ - 브랜치 taken/not taken            │
   │ ↓                                   │
   │ .gcov 파일 생성                     │
   │ - 소스 코드 + 실행 횟수              │
   │ - "1:" 실행 1회                     │
   │ - "#####:" 실행 안 됨               │
   │ - "-:" 실행 불가 (주석, 선언)        │
   └─────────────────────────────────────┘

Basic Block이란?
- 분기가 없는 연속된 명령어 블록
- 첫 명령어가 실행되면 마지막까지 순차 실행
- 예시:
  if (x > 0) {     // Block 1 끝
      y = x * 2;   // Block 2 시작
      z = y + 1;   // Block 2 계속
  }                // Block 2 끝
  return z;        // Block 3

카운터 삽입 위치:
- 정확히는 CFG의 간선(arc)에 카운터를 둡니다
- 모든 간선이 아니라 신장 트리에 속하지 않는 간선에만 두고,
  나머지 간선과 블록의 횟수는 gcov가 흐름 보존 법칙으로 계산합니다

기본 워크플로우

# 1. 커버리지 옵션으로 컴파일
g++ --coverage program.cpp -o program
# 또는 (동일)
g++ -fprofile-arcs -ftest-coverage program.cpp -o program

# --coverage 플래그 내부 동작:
# - 컴파일러가 카운터 증가 코드를 삽입
# - libgcov.a 라이브러리 자동 링크
# - .gcno 파일 생성 (그래프 구조 + 라인 매핑)

# 2. 프로그램 실행 (테스트 실행)
./program

# 실행 중 내부 동작:
# - 계측된 간선을 지날 때마다 메모리의 카운터 증가
# - 프로그램 정상 종료 시 .gcda 파일 쓰기 (기존 파일이 있으면 합산)

# 3. 커버리지 데이터 파일 생성 확인
ls *.gcda *.gcno
# program.gcno: 컴파일 시 생성 (그래프 정보, 라인 매핑)
# program.gcda: 실행 시 생성 (실행 카운트, 런타임 데이터)

# 4. gcov로 분석 (.gcno + .gcda → program.cpp.gcov)
gcov program.cpp

# 5. 결과 파일 확인
cat program.cpp.gcov

최적화(-O2 이상)와 함께 쓰면 라인 매핑이 어긋날 수 있어, 커버리지 전용 빌드 타입을 -O0 -g로 두는 경우가 많습니다. 또 GCC 11부터는 위처럼 컴파일과 링크를 한 명령으로 하면 데이터 파일 이름 앞에 출력 파일 이름이 붙어 program-program.gcno처럼 생길 수 있습니다. ls로 실제 이름을 확인하고, 이름이 다르면 gcov program-program.gcno처럼 그 파일을 직접 지정하거나 -c로 따로 컴파일한 뒤 링크합니다.

실전 예제: 계산기 프로그램

// 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 함수는 호출하지 않음!
    return 0;
}
# 컴파일 및 실행
$ g++ --coverage calculator.cpp -o calculator
$ ./calculator
10 + 5 = 15
10 - 5 = 5
10 * 5 = 50
# 커버리지 분석
$ gcov calculator.cpp
File 'calculator.cpp'
Lines executed:68.75% of 16
Creating 'calculator.cpp.gcov'
# 상세 리포트 확인
$ 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) {  // ##### = 실행 안 됨!
    #####:   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:}
# 해석:
# - "1:": 1번 실행됨
# - "#####:": 실행되지 않음 (미커버)
# - "-:": 실행 불가능한 라인 (주석, 빈 줄, 닫는 중괄호 등)

실행 가능한 줄로 세는 범위(함수 여는 줄, 닫는 중괄호 등)는 GCC 버전에 따라 조금씩 달라서, 같은 코드라도 분모가 다르게 나올 수 있습니다.

gcov 주요 옵션

# 브랜치 커버리지 포함 (-b), 실행 횟수로 표시 (-c)
gcov -b -c calculator.cpp
# 함수별 요약 (-f)
gcov -f calculator.cpp
# 출력 예시:
# Function 'divide'
# Lines executed:0.00% of 5
# ...
# 무조건 분기(unconditional branch)까지 표시 (-u, -b와 함께 사용)
gcov -b -u calculator.cpp
# 소스 경로를 포함한 이름으로 .gcov 생성 (-p, 같은 파일명이 여러 디렉터리에 있을 때)
gcov -p src/*.cpp

-u는 “미실행 줄만 표시”가 아니라 무조건 분기의 횟수까지 출력하는 옵션입니다. 미실행 줄은 .gcov 파일에서 #####로 표시된 줄을 찾으면 됩니다(grep -n '#####' calculator.cpp.gcov).

여러 파일 프로젝트

# 프로젝트 구조:
# src/
#   ├── main.cpp
#   ├── math.cpp
#   ├── math.h
#   └── utils.cpp
# 컴파일
g++ --coverage src/main.cpp src/math.cpp src/utils.cpp -o app
# 실행
./app
# 모든 소스 파일 분석
gcov src/*.cpp
# 또는 특정 파일만
gcov src/math.cpp

CMake와 통합

# CMakeLists.txt
cmake_minimum_required(VERSION 3.15)
project(MyProject)
set(CMAKE_CXX_STANDARD 17)
# 커버리지 빌드 타입 추가
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)
# 커버리지 타겟 추가
if(CMAKE_BUILD_TYPE STREQUAL "Coverage")
    add_custom_target(coverage
        COMMAND myapp
        COMMAND gcov -o ${CMAKE_CURRENT_BINARY_DIR}/CMakeFiles/myapp.dir/src/main.cpp.o
                ${CMAKE_CURRENT_SOURCE_DIR}/src/main.cpp
        COMMAND gcov -o ${CMAKE_CURRENT_BINARY_DIR}/CMakeFiles/myapp.dir/src/math.cpp.o
                ${CMAKE_CURRENT_SOURCE_DIR}/src/math.cpp
        WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}
        COMMENT "Generating coverage report"
    )
endif()

CMake는 오브젝트 파일을 CMakeFiles/<타깃>.dir/ 아래에 main.cpp.o처럼 만들고, .gcno/.gcda도 main.cpp.gcno/main.cpp.gcda로 그 옆에 생깁니다. 그래서 빌드 디렉터리에서 gcov main.cpp만 실행하면 데이터 파일을 찾지 못하고, -o로 오브젝트 파일 경로를 알려 줘야 합니다. 파일이 많아지면 이렇게 하나씩 적는 대신 아래의 lcov나 gcovr처럼 디렉터리 전체를 훑는 도구를 쓰는 편이 낫습니다.

# 빌드 및 커버리지 생성
mkdir build && cd build
cmake -DCMAKE_BUILD_TYPE=Coverage ..
make
make coverage

Google Test 통합

Google Test와 커버리지를 함께 사용하는 완전한 예제입니다.

프로젝트 구조

project/
├── CMakeLists.txt
├── src/
│   ├── string_utils.h
│   └── string_utils.cpp
└── test/
    └── string_utils_test.cpp

소스 코드

// src/string_utils.h
#pragma once
#include <string>
#include <vector>
class StringUtils {
public:
    static std::string trim(const std::string& str);
    static std::vector<std::string> split(const std::string& str, char delimiter);
    static bool startsWith(const std::string& str, const std::string& prefix);
    static bool endsWith(const std::string& str, const std::string& suffix);
};
// src/string_utils.cpp
#include "string_utils.h"
#include <algorithm>
std::string StringUtils::trim(const std::string& str) {
    auto start = str.find_first_not_of(" \t\n\r");
    if (start == std::string::npos) return "";
    
    auto end = str.find_last_not_of(" \t\n\r");
    return str.substr(start, end - start + 1);
}
std::vector<std::string> StringUtils::split(const std::string& str, char delimiter) {
    std::vector<std::string> tokens;
    std::string token;
    
    for (char c : str) {
        if (c == delimiter) {
            if (!token.empty()) {
                tokens.push_back(token);
                token.clear();
            }
        } else {
            token += c;
        }
    }
    
    if (!token.empty()) {
        tokens.push_back(token);
    }
    
    return tokens;
}
bool StringUtils::startsWith(const std::string& str, const std::string& prefix) {
    if (prefix.length() > str.length()) return false;
    return str.compare(0, prefix.length(), prefix) == 0;
}
bool StringUtils::endsWith(const std::string& str, const std::string& suffix) {
    if (suffix.length() > str.length()) return false;
    return str.compare(str.length() - suffix.length(), suffix.length(), suffix) == 0;
}
// test/string_utils_test.cpp
#include "string_utils.h"
#include <gtest/gtest.h>
TEST(StringUtilsTest, TrimSpaces) {
    EXPECT_EQ(StringUtils::trim("  hello  "), "hello");
    EXPECT_EQ(StringUtils::trim("hello"), "hello");
    EXPECT_EQ(StringUtils::trim("   "), "");
}
TEST(StringUtilsTest, SplitString) {
    auto result = StringUtils::split("a,b,c", ',');
    ASSERT_EQ(result.size(), 3);
    EXPECT_EQ(result[0], "a");
    EXPECT_EQ(result[1], "b");
    EXPECT_EQ(result[2], "c");
    
    // 빈 문자열
    auto empty = StringUtils::split("", ',');
    EXPECT_TRUE(empty.empty());
}
TEST(StringUtilsTest, StartsWith) {
    EXPECT_TRUE(StringUtils::startsWith("hello world", "hello"));
    EXPECT_FALSE(StringUtils::startsWith("hello world", "world"));
    EXPECT_FALSE(StringUtils::startsWith("hi", "hello"));
}
TEST(StringUtilsTest, EndsWith) {
    EXPECT_TRUE(StringUtils::endsWith("hello world", "world"));
    EXPECT_FALSE(StringUtils::endsWith("hello world", "hello"));
    EXPECT_FALSE(StringUtils::endsWith("hi", "world"));
}

CMakeLists.txt

cmake_minimum_required(VERSION 3.15)
project(StringUtilsProject)
set(CMAKE_CXX_STANDARD 17)
# Google Test 다운로드
include(FetchContent)
FetchContent_Declare(
    googletest
    GIT_REPOSITORY https://github.com/google/googletest.git
    GIT_TAG v1.14.0
)
FetchContent_MakeAvailable(googletest)
# 소스 라이브러리
add_library(string_utils src/string_utils.cpp)
target_include_directories(string_utils PUBLIC src)
# 테스트 실행 파일
add_executable(string_utils_test test/string_utils_test.cpp)
target_link_libraries(string_utils_test string_utils gtest gtest_main)
# 커버리지 설정
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)
    
    # 커버리지 타겟
    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 통합
enable_testing()
add_test(NAME string_utils_test COMMAND string_utils_test)

빌드 및 실행

# 커버리지 활성화하여 빌드
mkdir build && cd build
cmake -DENABLE_COVERAGE=ON ..
make
# 테스트 실행
./string_utils_test
# 출력:
# [==========] 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.
# 커버리지 리포트 생성
make coverage
# 출력:
# Generating coverage report
# Overall coverage rate:
#   lines......: 100.0% (24 of 24 lines)
#   functions..: 100.0% (4 of 4 functions)
# HTML 리포트 확인
open coverage_html/index.html

실전 워크플로우

일일 개발 워크플로우

# 1. 새 기능 개발
vim src/new_feature.cpp
# 2. 테스트 작성
vim test/new_feature_test.cpp
# 3. 빌드 및 테스트
mkdir -p build && cd build
cmake -DENABLE_COVERAGE=ON ..
make
./test_runner
# 4. 커버리지 확인
make coverage
# 5. 커버리지 미달 부분 확인
open coverage_html/index.html
# 6. 추가 테스트 작성 (미커버 부분)
vim test/new_feature_test.cpp
# 7. 다시 테스트 및 커버리지 확인
make && make coverage
# 8. 목표 달성 시 커밋
git add .
git commit -m "Add new feature with 90% coverage"

주기적인 커버리지 리뷰

cd build
make coverage
# 커버리지 요약을 날짜별로 저장해 추이 확인
lcov --summary coverage_filtered.info > coverage_$(date +%Y%m%d).txt
# 파일별 커버리지 목록에서 0%인 파일 찾기
lcov --list coverage_filtered.info | grep "0.0%"
# 핵심 모듈만 보기
lcov --list coverage_filtered.info | grep "src/core"

lcov로 시각화 (HTML 리포트)

lcov는 여러 .gcda/.gcno를 gcov로 처리해 하나의 tracefile(.info)로 모으고, genhtml로 소스별 HTML 리포트를 만드는 도구입니다.

설치

# Ubuntu/Debian
sudo apt install lcov
# macOS
brew install lcov
# Arch Linux
sudo pacman -S lcov

기본 사용법

# 1. 커버리지 옵션으로 컴파일
g++ --coverage src/*.cpp -o myapp -lgtest -lgtest_main
# 2. 테스트 실행
./myapp
# 3. 커버리지 데이터 수집
lcov --capture --directory . --output-file coverage.info
# 4. HTML 리포트 생성
genhtml coverage.info --output-directory coverage_html
# 5. 브라우저로 확인
open coverage_html/index.html  # macOS
xdg-open coverage_html/index.html  # Linux
start coverage_html/index.html  # Windows (Git Bash)

실전 예제: 완전한 워크플로우

# 프로젝트 구조:
# project/
#   ├── src/
#   │   ├── calculator.cpp
#   │   └── calculator.h
#   ├── test/
#   │   └── calculator_test.cpp
#   └── CMakeLists.txt
# calculator.h
#pragma once
class Calculator {
public:
    int add(int a, int b);
    int subtract(int a, int b);
    int multiply(int a, int b);
    int divide(int a, int b);
};
# calculator.cpp
#include "calculator.h"
#include <stdexcept>
int Calculator::add(int a, int b) {
    return a + b;
}
int Calculator::subtract(int a, int b) {
    return a - b;
}
int Calculator::multiply(int a, int b) {
    return a * b;
}
int Calculator::divide(int a, int b) {
    if (b == 0) {
        throw std::invalid_argument("Division by zero");
    }
    return a / b;
}
# calculator_test.cpp
#include "calculator.h"
#include <gtest/gtest.h>
TEST(CalculatorTest, Add) {
    Calculator calc;
    EXPECT_EQ(calc.add(2, 3), 5);
    EXPECT_EQ(calc.add(-1, 1), 0);
}
TEST(CalculatorTest, Subtract) {
    Calculator calc;
    EXPECT_EQ(calc.subtract(5, 3), 2);
}
TEST(CalculatorTest, Multiply) {
    Calculator calc;
    EXPECT_EQ(calc.multiply(3, 4), 12);
}
TEST(CalculatorTest, DivideNormal) {
    Calculator calc;
    EXPECT_EQ(calc.divide(10, 2), 5);
}
TEST(CalculatorTest, DivideByZero) {
    Calculator calc;
    EXPECT_THROW(calc.divide(10, 0), std::invalid_argument);
}
# 컴파일 및 실행
g++ --coverage src/calculator.cpp test/calculator_test.cpp \
    -o test_runner -lgtest -lgtest_main -pthread
./test_runner
# lcov 커버리지 수집
lcov --capture --directory . --output-file coverage.info
# 출력:
# Capturing coverage data from .
# Found gcov version: 11.0.0
# Scanning . for .gcda files ...
# Found 2 data files in .
# Processing calculator.gcda
# Finished .info-file creation
# 불필요한 파일 제외 (시스템 헤더, 테스트 파일)
lcov --remove coverage.info '/usr/*' '*/test/*' --output-file coverage_filtered.info
# HTML 리포트 생성
genhtml coverage_filtered.info --output-directory coverage_html \
    --title "Calculator Coverage Report" \
    --legend --show-details
# 출력:
# Reading data file coverage_filtered.info
# Found 1 entries.
# Writing .css and .png files.
# Generating output.
# Processing file src/calculator.cpp
# Writing directory view page.
# Overall coverage rate:
#   lines......: 100.0% (8 of 8 lines)
#   functions..: 100.0% (4 of 4 functions)

lcov 고급 기능

베이스라인 생성 (초기 상태)

# 테스트 실행 전 베이스라인 생성
lcov --capture --initial --directory . --output-file coverage_base.info
# 테스트 실행
./test_runner
# 테스트 후 커버리지 수집
lcov --capture --directory . --output-file coverage_test.info
# 두 데이터 결합
lcov --add-tracefile coverage_base.info \
     --add-tracefile coverage_test.info \
     --output-file coverage_total.info

특정 디렉토리만 포함

# src/ 디렉토리만 포함
lcov --capture --directory . --output-file coverage.info
lcov --extract coverage.info '*/src/*' --output-file coverage_src.info

여러 테스트 실행 결합

# 단위 테스트 실행
./unit_tests
lcov --capture --directory . --output-file coverage_unit.info
# 통합 테스트 실행
./integration_tests
lcov --capture --directory . --output-file coverage_integration.info
# 결합
lcov --add-tracefile coverage_unit.info \
     --add-tracefile coverage_integration.info \
     --output-file coverage_total.info
genhtml coverage_total.info --output-directory coverage_html

커버리지 임계값 설정

# 최소 커버리지 체크
genhtml coverage.info --output-directory coverage_html \
    --rc genhtml_hi_limit=90 \
    --rc genhtml_med_limit=70
# 임계값 미달 시 실패
lcov --summary coverage.info | grep "lines" | awk '{print $2}' | sed 's/%//' | \
    awk '{if ($1 < 80) exit 1}'

HTML 리포트 커스터마이징

# 상세한 리포트 옵션
genhtml coverage.info --output-directory coverage_html \
    --title "My Project Coverage" \
    --legend \
    --show-details \
    --demangle-cpp \
    --function-coverage \
    --branch-coverage \
    --num-spaces 4 \
    --sort \
    --prefix $(pwd)
# 출력 예시:
# Overall coverage rate:
#   lines......: 85.3% (123 of 144 lines)
#   functions..: 92.0% (23 of 25 functions)
#   branches...: 78.5% (51 of 65 branches)

CMake + lcov 자동화

# CMakeLists.txt
if(CMAKE_BUILD_TYPE STREQUAL "Coverage")
    find_program(LCOV lcov REQUIRED)
    find_program(GENHTML genhtml REQUIRED)
    
    add_custom_target(coverage
        # 기존 데이터 정리
        COMMAND ${LCOV} --directory . --zerocounters
        
        # 테스트 실행
        COMMAND $<TARGET_FILE:test_runner>
        
        # 커버리지 수집
        COMMAND ${LCOV} --capture --directory . --output-file coverage.info
        
        # 필터링
        COMMAND ${LCOV} --remove coverage.info '/usr/*' '*/test/*' 
                --output-file coverage_filtered.info
        
        # HTML 생성
        COMMAND ${GENHTML} coverage_filtered.info 
                --output-directory coverage_html
                --title "Project Coverage"
                --legend
        
        # 요약 출력
        COMMAND ${LCOV} --summary coverage_filtered.info
        
        WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
        COMMENT "Generating coverage report"
    )
endif()
# 사용
mkdir build && cd build
cmake -DCMAKE_BUILD_TYPE=Coverage ..
make
make coverage

커버리지 유형

코드 커버리지에는 여러 측정 방식이 있으며, 각각 다른 관점에서 테스트 완성도를 평가합니다.

라인 커버리지 (Line Coverage)

가장 기본적인 커버리지로, 실행된 코드 라인의 비율을 측정합니다.

int calculateScore(int base, int bonus, bool applyBonus) {
    int score = base;              // 라인 1: ✅ 항상 실행
    
    if (applyBonus) {              // 라인 2: ✅ 조건 체크
        score += bonus;            // 라인 3: ⚠️ applyBonus=true일 때만
    }
    
    int finalScore = score * 2;    // 라인 4: ✅ 항상 실행
    return finalScore;             // 라인 5: ✅ 항상 실행
}
// 테스트 1: applyBonus=false
TEST(Test, WithoutBonus) {
    EXPECT_EQ(calculateScore(10, 5, false), 20);
}
// 라인 커버리지: 80% (4/5 라인)
// 라인 3이 실행되지 않음!
// 테스트 2: applyBonus=true 추가
TEST(Test, WithBonus) {
    EXPECT_EQ(calculateScore(10, 5, true), 30);
}
// 라인 커버리지: 100% (5/5 라인)

측정 공식: (실행된 라인 수 / 전체 실행 가능 라인 수) × 100%

브랜치 커버리지 (Branch Coverage)

모든 조건문의 true/false 경로가 실행되었는지 측정합니다.

gcov는 브랜치를 따로 세는 것이 아니라 CFG의 간선 실행 횟수로 계산합니다. 조건 분기가 있는 블록에서 나가는 두 간선의 횟수가 곧 “taken”과 “not taken”입니다.

원본 코드:
if (a > b) { return a; } else { return b; }

CFG:
   ┌─────────────────┐
   │  Basic Block 0  │
   │  a > b 평가      │
   └─────────────────┘
      │ true    │ false     ← 이 두 간선의 실행 횟수 = 브랜치 0, 1
      ↓         ↓
   ┌────────┐ ┌────────┐
   │ BB 1   │ │ BB 2   │
   │return a│ │return b│
   └────────┘ └────────┘

max(5, 3)만 호출한 경우:
  branch 0 (true 간선):  1회
  branch 1 (false 간선): 0회  → 브랜치 커버리지 50% (1/2)

a && b 같은 단락 평가(short-circuit) 조건은 컴파일되면 조건마다 별도의 분기로 바뀝니다. 그래서 if (age >= 18 && hasLicense)는 gcov에서 분기 두 개(각각 true/false, 총 4개 결과)로 보이고, age < 18이면 hasLicense 분기는 평가되지 않습니다. 네 결과를 모두 보려면 (20, true), (20, false), (16, 아무 값) 세 가지 입력이면 충분합니다.

이보다 엄격한 기준이 MC/DC(Modified Condition/Decision Coverage)로, 각 조건이 다른 조건을 고정한 채 단독으로 결과를 바꾸는 입력 쌍이 있는지를 봅니다. 위 예에서는 (20, true)와 (16, true)가 age의 영향을, (20, true)와 (20, false)가 hasLicense의 영향을 보여 줍니다. 항공(DO-178C)이나 자동차(ISO 26262) 소프트웨어 인증에서 요구되는 기준이며, GCC는 14부터 -fcondition-coverage 옵션과 gcov --conditions로 MC/DC 측정을 지원합니다.

브랜치 커버리지 예시:

int max(int a, int b) {
    if (a > b) {                   // 분기: a > b (true/false)
        return a;
    } else {
        return b;
    }
}
// 테스트 1: true 경로만
TEST(Test, FirstGreater) {
    EXPECT_EQ(max(5, 3), 5);       // a > b = true
}
// 브랜치 커버리지: 50% (1/2 브랜치)
// 테스트 2: false 경로 추가
TEST(Test, SecondGreater) {
    EXPECT_EQ(max(3, 5), 5);       // a > b = false
}
// 브랜치 커버리지: 100% (2/2 브랜치)

복잡한 조건:

bool isValid(int age, bool hasLicense) {
    if (age >= 18 && hasLicense) {  // 조건 2개 → 분기 2개, 결과 4개
        return true;
    }
    return false;
}
// 완전한 브랜치 커버리지를 위한 테스트:
TEST(Test, AllBranches) {
    EXPECT_TRUE(isValid(20, true));    // age>=18: true, hasLicense: true
    EXPECT_FALSE(isValid(20, false));  // age>=18: true, hasLicense: false
    EXPECT_FALSE(isValid(16, true));   // age>=18: false, hasLicense: true
    EXPECT_FALSE(isValid(16, false));  // age>=18: false, hasLicense: false
}
// 브랜치 커버리지: 100%

gcov 브랜치 커버리지 확인:

# 컴파일 (--coverage가 -fprofile-arcs -ftest-coverage를 포함)
g++ --coverage -O0 program.cpp -o program

# 실행
./program

# 브랜치 커버리지 리포트 (-b: 브랜치 정보, -c: 비율 대신 횟수)
gcov -b -c program.cpp

# 출력 예시:
        -:    0:Source:program.cpp
        1:    1:int max(int a, int b) {
        1:    2:    if (a > b) {
branch  0 taken 1 (fallthrough)
branch  1 taken 0
        1:    3:        return a;
        -:    4:    } else {
    #####:    5:        return b;
        -:    6:    }
        -:    7:}

# lcov로 브랜치 커버리지 수집
# (lcov 1.x는 --rc lcov_branch_coverage=1, lcov 2.x는 --rc branch_coverage=1)
lcov --capture --directory . --output-file coverage.info --rc lcov_branch_coverage=1

# HTML 리포트에 브랜치 포함
genhtml coverage.info --output-directory coverage_html --branch-coverage

# 브랜치 커버리지 요약
lcov --summary coverage.info
# 출력:
# Overall coverage rate:
#   lines......: 85.3% (123 of 144 lines)
#   functions..: 92.0% (23 of 25 functions)
#   branches...: 78.5% (51 of 65 branches)

함수 커버리지 (Function Coverage)

호출된 함수의 비율을 측정합니다.

void func1() { /* ... */ }
void func2() { /* ... */ }
void func3() { /* ... */ }
TEST(Test, PartialFunctions) {
    func1();  // ✅ 호출됨
    func2();  // ✅ 호출됨
    // func3는 호출 안 됨 ❌
}
// 함수 커버리지: 66% (2/3 함수)

조건 커버리지 (Condition Coverage)

각 조건의 모든 가능한 값이 테스트되었는지 측정합니다.

bool complexCondition(bool a, bool b, bool c) {
    if (a && b || c) {  // 3개의 조건
        return true;
    }
    return false;
}
// 완전한 조건 커버리지:
TEST(Test, AllConditions) {
    // a의 true/false
    EXPECT_TRUE(complexCondition(true, true, false));   // a=T
    EXPECT_FALSE(complexCondition(false, true, false)); // a=F
    
    // b의 true/false
    EXPECT_TRUE(complexCondition(true, true, false));   // b=T
    EXPECT_FALSE(complexCondition(true, false, false)); // b=F
    
    // c의 true/false
    EXPECT_TRUE(complexCondition(false, false, true));  // c=T
    EXPECT_FALSE(complexCondition(false, false, false));// c=F
}

경로 커버리지 (Path Coverage)

모든 가능한 실행 경로를 테스트합니다 (가장 엄격).

int calculate(int x, int y) {
    int result = 0;
    
    if (x > 0) {        // 조건 1
        result += x;
    }
    
    if (y > 0) {        // 조건 2
        result += y;
    }
    
    return result;
}
// 가능한 경로: 2^2 = 4가지
// 1. x>0: false, y>0: false → result=0
// 2. x>0: false, y>0: true  → result=y
// 3. x>0: true,  y>0: false → result=x
// 4. x>0: true,  y>0: true  → result=x+y
TEST(Test, AllPaths) {
    EXPECT_EQ(calculate(-1, -1), 0);   // 경로 1
    EXPECT_EQ(calculate(-1, 5), 5);    // 경로 2
    EXPECT_EQ(calculate(3, -1), 3);    // 경로 3
    EXPECT_EQ(calculate(3, 5), 8);     // 경로 4
}
// 경로 커버리지: 100%

커버리지 유형 비교

유형측정 대상특징
라인실행된 코드 라인가장 기본적이고 도구 지원이 넓음. 한 줄 안의 조건 일부가 빠져도 100%로 보일 수 있음
브랜치조건문의 true/false 결과에러 처리 경로 누락을 잘 드러냄. C++에서는 예외 경로 분기 때문에 낮게 나오기 쉬움
함수한 번이라도 호출된 함수측정이 가장 거칠지만, 아예 테스트하지 않은 API를 찾는 데 유용
조건각 개별 조건의 true/false복합 조건에서 일부 조건이 한쪽 값만 가진 경우를 찾음
경로가능한 모든 실행 경로분기 수에 따라 지수적으로 늘어나 반복문이 있으면 사실상 측정 불가

대부분의 프로젝트는 라인 커버리지와 브랜치 커버리지를 함께 봅니다. 라인 커버리지로 아예 실행되지 않은 영역을 찾고, 브랜치 커버리지로 실행은 됐지만 한쪽 결과만 확인된 조건문을 찾는 식입니다.

자주 발생하는 문제

문제 1: .gcda 파일이 생성되지 않음

증상:

$ gcov program.cpp
program.gcda:cannot open data file, assuming not executed

원인:

  • 프로그램이 실행되지 않음
  • 프로그램이 비정상 종료 (SIGKILL, SIGSEGV, 기본 동작의 SIGTERM, abort(), _exit())
  • 쓰기 권한 없음

.gcda는 실행 중에 조금씩 쓰이는 것이 아니라, 프로그램이 정상 종료할 때(main 반환이나 exit() 호출 시 실행되는 종료 처리기에서) 카운터를 한꺼번에 기록합니다. 그래서 크래시하거나 시그널로 죽은 프로세스는 그때까지 실행한 기록을 전부 잃습니다. 서버처럼 시그널로 멈추는 프로그램을 측정하려면 SIGTERM 핸들러에서 플래그를 세우고 main이 정상 반환하게 만들거나, 필요한 시점에 __gcov_dump()를 직접 호출해야 합니다. .gcda는 오브젝트 파일이 만들어진 빌드 디렉터리의 절대 경로에 쓰이므로, 빌드한 머신과 다른 곳(컨테이너, 다른 CI 러너)에서 테스트를 실행하면 경로가 없어 조용히 실패합니다. 이때 쓰는 것이 아래의 GCOV_PREFIX/GCOV_PREFIX_STRIP 환경 변수입니다. 해결법:

# 1. 프로그램이 정상 종료되는지 확인
./program
echo $?  # 0이어야 함
# 2. 권한 확인
ls -la *.gcda
chmod 644 *.gcda
# 3. 환경 변수 설정 (경로 지정)
export GCOV_PREFIX=/tmp/coverage
export GCOV_PREFIX_STRIP=1
./program

문제 2: 커버리지 데이터 누적

증상:

# 첫 실행: 50%
# 두 번째 실행: 75%
# 세 번째 실행: 100%
# 데이터가 누적됨!

해결법:

# 방법 1: .gcda 파일 삭제
find . -name "*.gcda" -delete
./program
gcov program.cpp
# 방법 2: lcov로 초기화
lcov --directory . --zerocounters
./program
lcov --capture --directory . --output-file coverage.info
# 방법 3: CMake 타겟에 자동화
add_custom_target(coverage
    COMMAND ${LCOV} --directory . --zerocounters
    COMMAND $<TARGET_FILE:test_runner>
    COMMAND ${LCOV} --capture --directory . --output-file coverage.info
    # ...
)

문제 3: 인라인 함수 커버리지 누락

증상:

// header.h
inline int add(int a, int b) {
    return a + b;  // 커버리지에 나타나지 않음
}

원인: -O0 커버리지 빌드에서는 인라인 함수도 실제로 인라인되지 않고 별도 함수로 만들어져 정상적으로 측정됩니다. 문제는 한 번도 호출되지 않은 인라인 함수와 템플릿입니다. 컴파일러는 쓰이지 않는 인라인 함수의 코드를 아예 생성하지 않으므로 계측 대상에도 들어가지 않고, 리포트에 “미실행”으로 표시되는 대신 아예 빠져서 커버리지 비율이 실제보다 높게 나옵니다. 해결법:

# 방법 1: 사용되지 않는 인라인 함수도 코드를 생성하도록 강제 (GCC)
g++ --coverage -O0 -fkeep-inline-functions ...
// 방법 2: 구현 파일로 이동
// header.h
int add(int a, int b);
// source.cpp
int add(int a, int b) {
    return a + b;
}

커버리지 빌드에서만 inline을 매크로로 지우는 방법이 인터넷에 돌아다니는데, 헤더에 정의된 함수에서 inline을 빼면 그 헤더를 포함한 여러 .cpp마다 정의가 생겨 multiple definition of 'add' 링크 에러가 납니다. 빌드 설정을 바꾸는 쪽(-fkeep-inline-functions)이 안전합니다.

문제 4: 템플릿 커버리지 측정

증상:

template<typename T>
T maxOf(T a, T b) {
    return a > b ? a : b;
}
TEST(Test, MaxInt) {
    EXPECT_EQ(maxOf(3, 5), 5);  // int 인스턴스만 생성·실행됨
}

템플릿은 사용된 타입마다 별도 함수로 인스턴스화되고, gcov는 같은 소스 줄에 대해 인스턴스별 카운트를 합쳐서 보여 줍니다. 한 번도 인스턴스화되지 않은 템플릿은 코드 자체가 없으므로 리포트에서 미실행이 아니라 아예 빠집니다.

해결법:

// 실제로 쓰는 타입을 테스트
TEST(Test, MaxAllTypes) {
    EXPECT_EQ(maxOf(3, 5), 5);                                  // int
    EXPECT_DOUBLE_EQ(maxOf(3.5, 5.2), 5.2);                     // double
    EXPECT_EQ(maxOf(std::string("a"), std::string("b")), "b");  // string
}
// 명시적 인스턴스화: 테스트하지 않은 인스턴스도 코드가 생성되어
// 리포트에 "미실행"으로 나타나게 함 (커버리지를 올려 주지는 않음)
template int maxOf<int>(int, int);
template double maxOf<double>(double, double);

예제 함수 이름을 max로 지으면 std::string 인자에서 ADL로 std::max도 후보가 되어 호출이 모호해질 수 있으므로 피하는 편이 좋습니다.

문제 5: 최적화로 인한 커버리지 왜곡

증상:

# -O2로 컴파일 시 일부 코드가 최적화되어 사라짐
g++ --coverage -O2 program.cpp

해결법:

# 커버리지 측정 시 최적화 끄기
g++ --coverage -O0 program.cpp
# CMakeLists.txt
if(ENABLE_COVERAGE)
    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} --coverage -O0")
endif()

문제 6: 멀티스레드 프로그램 커버리지

증상: 여러 스레드가 실행한 코드의 실행 횟수가 실제보다 적게 기록되거나, 드물게 실행된 줄이 미실행으로 표시됨 원인: .gcda 파일은 프로세스 종료 시 한 번만 기록되므로 스레드끼리 파일 쓰기가 충돌하지는 않습니다. 문제는 메모리 안의 카운터 증가입니다. 기본 계측 코드는 카운터를 원자적이지 않게 ++하므로, 여러 스레드가 같은 코드를 동시에 실행하면 증가분이 유실됩니다(데이터 레이스). 해결법:

# 방법 1: 카운터를 원자적으로 증가 (GCC 7+)
g++ --coverage -O0 -fprofile-update=atomic ...
# -pthread로 빌드하면 GCC가 prefer-atomic을 고르는 경우도 있지만 명시하는 것이 확실
// 방법 2: 필요한 시점에 카운터를 파일로 기록 (GCC 11+, 이전 버전의 __gcov_flush 대체)
#include <gcov.h>
void on_shutdown() {
    __gcov_dump();   // 현재까지의 카운터를 .gcda에 기록
}

원자적 증가는 계측 코드가 조금 느려지지만, “실행했는데 0으로 나오는” 줄을 없애 줍니다. __gcov_flush()는 GCC 11에서 제거되었으므로 오래된 예제를 그대로 쓰면 undefined reference to '__gcov_flush' 링크 에러가 나며, __gcov_dump()(기록)와 __gcov_reset()(초기화)로 바꿔 써야 합니다. 여러 프로세스가 같은 .gcda에 결과를 합치는 것(예: 테스트를 병렬 프로세스로 실행)은 libgcov가 파일 잠금으로 병합해 주므로 문제가 되지 않습니다.

문제 7: 낮은 커버리지 원인 분석

# 파일별 커버리지에서 0%인 파일 찾기
lcov --list coverage.info | grep "0.0%"
# .gcov 생성 후 미실행 줄 찾기 (#####는 stdout이 아니라 .gcov 파일에 있음)
gcov -b program.cpp > /dev/null
grep -n "#####" program.cpp.gcov
# 한 번도 실행되지 않은 분기 찾기 (.gcov 파일의 "branch N never executed" 줄)
grep -n "never executed" program.cpp.gcov
# 함수별 커버리지 요약
gcov -f program.cpp

헤더와 테스트 코드가 섞여 비율이 왜곡될 때

lcov의 --capture 결과에는 /usr/include의 표준 라이브러리 헤더, googletest 소스, 테스트 코드 자체가 모두 들어갑니다. 테스트 코드는 거의 100% 실행되므로 전체 비율을 부풀리고, 표준 라이브러리 헤더는 반대로 비율을 떨어뜨립니다. lcov --remove coverage.info '/usr/*' '*/tests/*' '*/_deps/*' -o filtered.info처럼 측정 대상이 아닌 경로를 빼고, 가능하면 --extract coverage.info '*/src/*'처럼 포함할 경로를 명시하는 편이 결과가 안정적입니다. CI 게이트는 반드시 이렇게 걸러 낸 파일 기준으로 설정해야 합니다.

커버리지를 운영하는 방법

커버리지 목표 설정

모든 프로젝트에 맞는 단일 숫자는 없습니다. 참고할 만한 공개 기준으로는 Google Testing Blog의 “Code Coverage Best Practices”(2020)가 있는데, 60%를 수용 가능(acceptable), 75%를 권장할 만한(commendable), 90%를 모범적인(exemplary) 수준으로 소개하면서도 숫자 하나를 강제하기보다 코드의 위험도에 맞춰 판단하라고 강조합니다. 실무에서는 오류가 비싼 핵심 로직과 에러 처리 경로에 더 높은 기준을 두고, 생성 코드나 UI 접착 코드는 낮게 두는 식으로 모듈별로 다르게 잡는 경우가 많습니다. 100%를 목표로 하면 의미 없는 테스트가 늘고 유지보수 비용이 커지기 쉽습니다.

테스트 우선순위

// 우선순위 1: 에러 처리 경로
TEST(HighPriority, ErrorHandling) {
    EXPECT_THROW(divide(10, 0), std::exception);
    EXPECT_THROW(parseJson("invalid"), std::exception);
}
// 우선순위 2: 경계 조건
TEST(HighPriority, BoundaryConditions) {
    EXPECT_EQ(factorial(0), 1);  // 최소값
    EXPECT_EQ(factorial(20), 2432902008176640000ULL);  // uint64_t에 들어가는 최대 n
}
// 우선순위 3: 일반적인 케이스
TEST(MediumPriority, NormalCases) {
    EXPECT_EQ(add(2, 3), 5);
}
// 우선순위 4: 드문 케이스
TEST(LowPriority, RareCases) {
    // 실제로 거의 발생하지 않는 경우
}

커버리지 리포트 활용

#!/bin/bash
# 주기적인 커버리지 비교
# 1. 현재 커버리지 측정
make coverage
# 2. 이전 값과 비교 (bc 비교를 위해 % 제거)
CURRENT=$(lcov --summary coverage.info | grep "lines" | awk '{print $2}' | sed 's/%//')
PREVIOUS=$(cat coverage_last_week.txt)
echo "Current: $CURRENT"
echo "Previous: $PREVIOUS"
# 3. 감소 시 경고
if (( $(echo "$CURRENT < $PREVIOUS" | bc -l) )); then
    echo "WARNING: Coverage decreased!"
    exit 1
fi
# 4. 현재 값 저장
echo "$CURRENT" > coverage_last_week.txt

커버리지 뱃지 추가

# README.md
[![codecov](https://codecov.io/gh/username/repo/branch/main/graph/badge.svg)](https://codecov.io/gh/username/repo)
# 또는 shields.io
![Coverage](https://img.shields.io/badge/coverage-85%25-brightgreen)

커버리지 게이트 설정

# .github/workflows/coverage.yml
- name: Coverage gate
  run: |
    COVERAGE=$(lcov --summary coverage.info | grep "lines" | \
               awk '{print $2}' | sed 's/%//')
    if (( $(echo "$COVERAGE < 80" | bc -l) )); then
      echo "::error::Coverage $COVERAGE% is below 80%"
      exit 1
    fi

점진적 개선 전략

현재 커버리지가 낮은 프로젝트에 처음부터 높은 절대 기준을 걸면 모든 PR이 실패합니다. 처음에는 리포트만 올려 현재 상태를 파악하고, 다음 단계로 “기존보다 낮아지면 실패”하는 상대 기준을, 그다음 새로 바뀐 코드(patch)에만 높은 기준을 거는 순서가 현실적입니다. Codecov를 쓴다면 이전 커밋 대비 감소를 막는 설정을 codecov.yml로 바로 걸 수 있습니다.

# codecov.yml
coverage:
  status:
    project:
      default:
        target: auto      # 기준 커밋 대비 커버리지
        threshold: 1%     # 1%p까지 감소 허용
    patch:
      default:
        target: 80%       # PR에서 바뀐 줄의 커버리지

테스트 품질 > 커버리지 수치

// ❌ 나쁜 예: 커버리지만 높임
TEST(BadTest, JustForCoverage) {
    MyClass obj;
    obj.method1();
    obj.method2();
    obj.method3();
    // 아무 검증 없음!
}
// ✅ 좋은 예: 의미 있는 테스트
TEST(GoodTest, Method1Behavior) {
    MyClass obj;
    int result = obj.method1();
    EXPECT_EQ(result, 42);
    EXPECT_TRUE(obj.isValid());
}
TEST(GoodTest, Method2ErrorHandling) {
    MyClass obj;
    EXPECT_THROW(obj.method2(-1), std::invalid_argument);
}

커버리지 제외 설정

// 테스트하기 어려운 코드 제외
// LCOV_EXCL_START
void platformSpecificCode() {
    #ifdef _WIN32
        // Windows 전용 코드
    #endif
}
// LCOV_EXCL_STOP
// 단일 라인 제외
int unreachableCode() {
    return 42;  // LCOV_EXCL_LINE
}
# lcov 필터링
lcov --remove coverage.info \
     '*/test/*' \
     '*/third_party/*' \
     '*/generated/*' \
     --output-file coverage_filtered.info

CI/CD 통합

GitHub Actions

# .github/workflows/coverage.yml
name: Code Coverage
on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]
jobs:
  coverage:
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      
      - name: Install dependencies
        run: |
          sudo apt-get update
          sudo apt-get install -y lcov cmake g++
      
      - name: Configure CMake
        run: |
          mkdir build
          cd build
          cmake -DENABLE_COVERAGE=ON ..
      
      - name: Build
        run: cmake --build build
      
      - name: Run tests
        run: |
          cd build
          ctest --output-on-failure
      
      - 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
          lcov --list coverage_filtered.info
      
      - name: Upload to Codecov
        uses: codecov/codecov-action@v5
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          files: ./build/coverage_filtered.info
          flags: unittests
          name: codecov-umbrella
          fail_ci_if_error: true
      
      - name: Check coverage threshold
        run: |
          cd build
          COVERAGE=$(lcov --summary coverage_filtered.info | \
                     grep "lines" | awk '{print $2}' | sed 's/%//')
          echo "Coverage: $COVERAGE%"
          if (( $(echo "$COVERAGE < 80" | bc -l) )); then
            echo "Coverage $COVERAGE% is below threshold 80%"
            exit 1
          fi
      
      - name: Generate HTML report
        run: |
          cd build
          genhtml coverage_filtered.info --output-directory coverage_html
      
      - name: Upload HTML report
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: build/coverage_html

GitHub Actions의 actions/upload-artifact@v3는 2025년 1월에 지원이 종료되어 지금은 v4를 써야 하고, codecov/codecov-action은 v4부터 업로드 토큰을 요구하는 것이 기본입니다. 오래된 예제의 액션 버전을 그대로 복사하면 워크플로가 실패하므로 버전을 먼저 확인합니다.

GitLab CI

# .gitlab-ci.yml
stages:
  - build
  - test
  - coverage
variables:
  GIT_SUBMODULE_STRATEGY: recursive
build:
  stage: build
  image: gcc:latest
  script:
    - apt-get update && apt-get install -y cmake lcov
    - mkdir build && cd build
    - cmake -DENABLE_COVERAGE=ON ..
    - make
  artifacts:
    paths:
      - build/
    expire_in: 1 hour
test:
  stage: test
  image: gcc:latest
  dependencies:
    - build
  script:
    - cd build
    - ctest --output-on-failure
  artifacts:
    paths:
      - build/
    expire_in: 1 hour
coverage:
  stage: coverage
  image: gcc:latest
  dependencies:
    - test
  script:
    - apt-get update && apt-get install -y lcov gcovr
    - cd build
    - lcov --capture --directory . --output-file coverage.info
    - lcov --remove coverage.info '/usr/*' '*/test/*' --output-file coverage_filtered.info
    - lcov --list coverage_filtered.info
    - genhtml coverage_filtered.info --output-directory coverage_html
    # 커버리지 퍼센트 추출
    - COVERAGE=$(lcov --summary coverage_filtered.info | grep "lines" | awk '{print $2}' | sed 's/%//')
    - echo "Coverage is $COVERAGE%"
    # GitLab MR 화면의 줄 단위 표시용 Cobertura XML
    - gcovr --root .. --filter ../src/ --cobertura coverage.xml
  coverage: '/lines\.*: (\d+\.\d+)%/'
  artifacts:
    paths:
      - build/coverage_html
    reports:
      coverage_report:
        coverage_format: cobertura
        path: build/coverage.xml

Jenkins Pipeline

// Jenkinsfile
pipeline {
    agent any
    
    environment {
        CC = 'gcc'
        CXX = 'g++'
    }
    
    stages {
        stage('Checkout') {
            steps {
                checkout scm
            }
        }
        
        stage('Build') {
            steps {
                sh '''
                    mkdir -p build
                    cd build
                    cmake -DENABLE_COVERAGE=ON ..
                    make
                '''
            }
        }
        
        stage('Test') {
            steps {
                sh '''
                    cd build
                    ctest --output-on-failure --output-junit ctest-results.xml
                '''
            }
        }
        
        stage('Coverage') {
            steps {
                sh '''
                    cd build
                    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
                    
                    # 커버리지 체크
                    COVERAGE=$(lcov --summary coverage_filtered.info | \
                               grep "lines" | awk '{print $2}' | sed 's/%//')
                    echo "Coverage: $COVERAGE%"
                    
                    if (( $(echo "$COVERAGE < 80" | bc -l) )); then
                        echo "WARNING: Coverage $COVERAGE% is below 80%"
                    fi
                '''
            }
        }
        
        stage('Publish') {
            steps {
                publishHTML([
                    reportDir: 'build/coverage_html',
                    reportFiles: 'index.html',
                    reportName: 'Coverage Report'
                ])
            }
        }
    }
    
    post {
        always {
            junit 'build/ctest-results.xml'
        }
        success {
            echo 'Build and tests passed!'
        }
        failure {
            echo 'Build or tests failed!'
        }
    }
}

Codecov 통합

# codecov.yml (프로젝트 루트)
coverage:
  status:
    project:
      default:
        target: 80%
        threshold: 2%
    patch:
      default:
        target: 90%
        
ignore:
  - "test/**/*"
  - "examples/**/*"
  - "third_party/**/*"
comment:
  layout: "reach, diff, flags, files"
  behavior: default
  require_changes: false
# 로컬에서 Codecov 업로드 (Codecov CLI, 옛 bash 업로더는 폐기됨)
pip install codecov-cli
codecovcli upload-process -t "$CODECOV_TOKEN" -f build/coverage_filtered.info

예전 문서에 자주 나오는 bash <(curl -s https://codecov.io/bash) 업로더는 2021년 공급망 공격 사건 이후 폐기되었습니다. 외부 스크립트를 내려받아 바로 실행하는 방식은 그 스크립트가 변조되면 CI 환경 변수(토큰, 자격 증명)가 그대로 유출될 수 있다는 점을 보여 준 사례이므로, GitHub Actions에서는 codecov/codecov-action을 버전 고정해서 쓰고, 그 밖의 환경에서는 공식 CLI를 쓰는 것이 좋습니다.

도구 비교

주요 커버리지 도구

gcov는 GCC에 포함되어 별도 설치가 필요 없지만 결과가 소스 파일별 텍스트라 프로젝트 전체를 보기 어렵습니다. lcov는 gcov 결과를 모아 HTML 리포트를 만들어 로컬에서 미실행 줄을 눈으로 찾기에 좋고, gcovr는 같은 데이터를 Cobertura XML·JSON·HTML 등 CI 도구가 읽는 형식으로 내보내는 데 강합니다. Codecov나 Coveralls는 이렇게 만든 리포트를 업로드받아 PR 코멘트, 변경 줄(patch) 커버리지, 추이 그래프를 제공하는 호스팅 서비스이고, SonarQube는 커버리지를 정적 분석 결과와 함께 품질 게이트로 묶습니다. 항공·자동차처럼 MC/DC 같은 인증 수준 측정이 필요한 분야에서는 BullseyeCoverage 같은 상용 도구를 쓰기도 합니다.

컴파일러별 커버리지 도구

# GCC/G++
g++ --coverage source.cpp
# Clang/Clang++ (소스 기반 커버리지)
clang++ -fprofile-instr-generate -fcoverage-mapping source.cpp
./a.out                                  # default.profraw 생성
llvm-profdata merge -sparse default.profraw -o default.profdata
llvm-cov show ./a.out -instr-profile=default.profdata
# MSVC: Visual Studio의 "코드 커버리지 분석" 또는
# Microsoft.CodeCoverage.Console 도구로 측정 (/PROFILE 링커 옵션이 필요한 경우가 있음)

계측 방식의 차이

GCC의 --coverage(gcov)와 Clang의 -fprofile-instr-generate -fcoverage-mapping(소스 기반 커버리지)은 측정 방식이 다릅니다. gcov는 컴파일된 코드의 기본 블록과 분기에 카운터를 붙이므로, 최적화나 컴파일러가 만든 코드(예외 처리 경로)가 결과에 섞여 나옵니다. Clang의 소스 기반 커버리지는 소스 코드의 영역(region)에 카운터를 대응시키므로 한 줄 안의 a && b 같은 조건도 부분별로 보여 주고, 최적화 수준이 달라도 결과가 비교적 안정적입니다. 반면 .profraw 파일이 크고 llvm-profdata merge 단계가 추가됩니다. 두 방식 모두 계측 때문에 테스트 실행 시간이 늘어나므로, 커버리지 빌드는 일반 테스트 빌드와 분리해 두는 것이 보통입니다.

C++에서 gcov 브랜치 커버리지를 처음 보면 기대보다 훨씬 낮게 나와 당황하는 경우가 많습니다. 예외를 던질 수 있는 함수 호출마다 컴파일러가 “예외가 전파되는 경로”를 분기로 만들기 때문에, std::string 생성이나 push_back 한 줄에도 테스트로는 거의 닿지 않는 분기가 여러 개 붙습니다. gcovr를 쓴다면 --exclude-throw-branches와 --exclude-unreachable-branches 옵션으로 이런 분기를 빼고 볼 수 있어, 실제로 의미 있는 if/switch 분기에 집중할 수 있습니다.

참고 자료


같이 보면 좋은 글