본문으로 건너뛰기
고급약 40분

커버리지 도구 실습 — JaCoCo·Istanbul·coverage.py로 리포트를 뽑고 CI 게이트까지

커버리지 개념은 모듈 3과 모듈 10에서 배웠다. 여기서는 도구를 직접 돌린다. JaCoCo는 바이트코드를, Istanbul은 변환된 소스를, coverage.py는 실행 추적을 계측한다는 차이가 리포트 숫자를 어떻게 바꾸는지 확인하고, 단위·통합·E2E 커버리지를 병합하고, 전체 커버리지 대신 변경분 커버리지로 CI 게이트를 거는 법까지 실습한다.

  • #커버리지
  • #JaCoCo
  • #Istanbul
  • #coveragepy
  • #품질게이트

개념

도구마다 "무엇을 세는가"가 다르다

모듈 3(레슨 7)에서 구문·분기·조건·MC/DC 커버리지를 손으로 계산했고, 모듈 10(레슨 5)에서 라인 커버리지가 높아도 분기가 비어 있을 수 있다는 것을 봤다. 실무에서 만나는 숫자는 손계산이 아니라 도구가 만든다. 그런데 도구마다 계측 지점이 다르기 때문에, 같은 코드·같은 테스트라도 숫자가 다르게 나온다.

도구대상계측 방식기본 카운터
JaCoCoJVM 바이트코드실행 시 에이전트가 클래스 로딩 중 프로브 삽입instruction, branch, line, method, class, cyclomatic complexity
Istanbul(nyc)JS/TS 소스Babel 플러그인이 소스를 변환해 카운터 변수 삽입statements, branches, functions, lines
V8 커버리지(c8, Vitest v8 provider)실행 중인 JS엔진 내장 커버리지 카운터를 읽음바이트 범위 기반 → statements/branches/lines로 환산
coverage.pyPython인터프리터 추적 훅(sys.monitoring/settrace)statements, missing, branch(부분 분기), arcs

이 차이가 실제 숫자를 흔드는 대표적인 예가 JaCoCo의 instruction 커버리지 다. JaCoCo는 소스 한 줄이 아니라 바이트코드 명령 단위로 센다. 그래서 한 줄에 여러 연산이 들어간 코드는 "라인은 실행됨, instruction은 절반만 실행됨"이 된다.

// 한 줄이지만 바이트코드는 여러 갈래다
int fee = isVip ? 0 : (total >= 50_000 ? 0 : 3_000);

VIP 케이스만 테스트하면 JaCoCo 리포트에서 이 줄은 노란색(부분 커버)으로 표시되고, line coverage는 1/1이지만 branch coverage는 1/4이 된다. 라인 숫자만 보면 놓치는 정보다. 리포트에서 색을 먼저 보라는 조언은 여기서 나온다 — 초록(전부), 노랑(일부 분기), 빨강(미실행).

실행 — JVM / JaCoCo

Gradle Kotlin DSL 기준으로 리포트 생성과 게이트를 함께 건다.

// build.gradle.kts
plugins {
    java
    jacoco
}
 
jacoco {
    toolVersion = "0.8.14"   // Java 신버전 지원은 JaCoCo 버전에 묶여 있다. 변경 이력 확인
}
 
tasks.test {
    useJUnitPlatform()
    finalizedBy(tasks.jacocoTestReport)
}
 
tasks.jacocoTestReport {
    dependsOn(tasks.test)
    reports {
        xml.required.set(true)      // CI·diff-cover·SonarQube용
        html.required.set(true)     // 사람이 읽는 용
        csv.required.set(false)
    }
    classDirectories.setFrom(
        files(classDirectories.files.map {
            fileTree(it) {
                exclude("**/config/**", "**/dto/**", "**/generated/**")
            }
        })
    )
}
 
tasks.jacocoTestCoverageVerification {
    violationRules {
        rule {
            element = "CLASS"
            includes = listOf("com.example.payment.*")
            limit {
                counter = "BRANCH"
                value = "COVEREDRATIO"
                minimum = "0.80".toBigDecimal()
            }
        }
    }
}
./gradlew test jacocoTestReport jacocoTestCoverageVerification
open build/reports/jacoco/test/html/index.html

exclude를 어디까지 쓸 것인지가 첫 번째 정직성 시험이다. DTO·설정 클래스처럼 로직이 없는 코드를 빼는 것은 타당하다. 반대로 커버리지가 낮게 나온다는 이유로 서비스 패키지를 제외하면 숫자는 올라가고 정보는 사라진다. 제외 규칙은 코드로 남고 리뷰를 받아야 한다.

실행 — JavaScript / Istanbul(nyc)

npm i -D nyc
npx nyc --reporter=text --reporter=html --reporter=lcov mocha
// .nycrc.json
{
  "all": true,
  "include": ["src/**/*.ts"],
  "exclude": ["src/**/*.d.ts", "src/**/index.ts", "**/*.spec.ts"],
  "extension": [".ts"],
  "check-coverage": true,
  "branches": 80,
  "functions": 85,
  "lines": 85,
  "statements": 85,
  "report-dir": "coverage"
}

"all": true 가 중요하다. 이 옵션이 없으면 테스트가 한 번도 import 하지 않은 파일은 리포트에 아예 나타나지 않는다. 커버리지 90%처럼 보이지만 실제로는 테스트가 건드린 파일들 안에서만 90%인 상황이 여기서 생긴다.

Vitest나 Jest를 쓰면 러너가 커버리지를 직접 제공한다.

// vitest.config.ts
import { defineConfig } from 'vitest/config'
 
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',            // 'istanbul' 로 바꾸면 소스 계측 방식
      reporter: ['text', 'html', 'lcov'],
      include: ['src/**'],
      thresholds: { branches: 80, lines: 85 },
    },
  },
})

v8 프로바이더는 엔진 카운터를 읽어 빠르지만, 트랜스파일된 코드의 위치를 소스 맵으로 되돌리는 과정에서 분기 경계가 Istanbul과 미세하게 달라질 수 있다. 프로바이더를 바꾼 뒤 숫자가 몇 %p 움직였다면 그건 테스트가 변한 것이 아니라 잣대가 달라진 것이다. 게이트 임계값을 조정하기 전에 먼저 이 점을 확인한다.

실행 — Python / coverage.py

python -m pip install coverage diff-cover
python -m coverage run --branch -m pytest
python -m coverage report --show-missing --skip-covered
python -m coverage html
python -m coverage xml          # diff-cover·Sonar 입력용
# .coveragerc
[run]
branch = True
source = app
omit =
    app/migrations/*
    app/settings/*
parallel = True
 
[report]
show_missing = True
skip_empty = True
exclude_lines =
    pragma: no cover
    if TYPE_CHECKING:
    raise NotImplementedError
fail_under = 80

--branch 를 빼면 coverage.py는 문장 커버리지만 센다. 기본값이 꺼져 있으므로 분기 커버리지를 보고 있다고 믿는데 실은 문장 커버리지만 보고 있는 팀이 흔하다. 리포트 표의 Branch·BrPart 열이 보이는지로 확인한다.

Name                     Stmts   Miss Branch BrPart  Cover   Missing
------------------------------------------------------------------------
app/pricing.py              42      3     16      4    88%   57-59, 33->36, 44->exit

33->36 은 "33번 줄에서 36번 줄로 가는 분기가 한 번도 실행되지 않았다"는 뜻이고, 44->exit 은 "44번 줄의 조건이 거짓이어서 함수를 빠져나가는 경로가 없었다"는 뜻이다. 줄 번호 하나가 아니라 화살표(arc)를 읽는 것이 coverage.py 리포트를 제대로 읽는 법이다.

리포트에서 실제로 봐야 할 것

숫자 하나를 보지 말고 세 가지를 본다.

  1. 빨간 줄의 목록 — 미실행 코드가 어떤 성격인지. 에러 처리 분기만 빨갛다면 예외 시나리오 테스트가 없는 것이다
  2. 노란 줄(부분 분기) — 라인 커버리지가 숨기는 곳. 여기가 진짜 구멍이다
  3. 커버리지가 0%인 파일 — 존재 자체를 잊은 모듈이 여기서 드러난다. all: true, source= 설정이 있어야 보인다
JaCoCo HTML 리포트 열 읽기
 
Element        Missed Instructions  Cov.  Missed Branches  Cov.  Cxty  Missed  Lines
PaymentService ████░░░░░░░░░░░░░    72%   ██░░░░░░░░░░     45%    24      7     86
               │                          │                       │
               바이트코드 명령 기준          조건 분기 기준           순환 복잡도 합

Cxty(순환 복잡도) 열이 함께 있다는 점이 JaCoCo의 장점이다. 복잡도가 높은데 branch 커버리지가 낮은 클래스 가 가장 위험한 조합이고, 리포트를 이 두 열로 정렬하면 어디부터 손댈지가 바로 나온다(모듈 3 레슨 10에서 배운 복잡도 해석을 여기에 그대로 적용한다).

통합·E2E 테스트의 커버리지도 모은다

커버리지를 단위 테스트에서만 재면 "통합 구간은 아무도 안 본다"가 된다. 서버를 띄워 놓고 재는 방법이 있다.

# JVM: 애플리케이션을 JaCoCo 에이전트와 함께 띄우고, E2E가 끝난 뒤 덤프를 받는다
java -javaagent:jacocoagent.jar=output=tcpserver,address=*,port=6300 \
     -jar build/libs/app.jar
 
# E2E 실행 후
java -jar jacococli.jar dump --address localhost --port 6300 --destfile e2e.exec
java -jar jacococli.jar report unit.exec e2e.exec \
     --classfiles build/classes/java/main --sourcefiles src/main/java \
     --xml merged.xml --html merged-html
# JS: 여러 실행 결과를 병합한다
npx nyc merge coverage-unit coverage-merged/unit.json
npx nyc merge coverage-e2e  coverage-merged/e2e.json
npx nyc report --temp-dir coverage-merged --reporter=lcov --reporter=text
# Python: 병렬·다중 프로세스 실행 결과 합치기
python -m coverage combine
python -m coverage report

병합해 보면 대부분의 팀에서 단위 테스트가 커버하는 영역과 E2E가 커버하는 영역이 크게 겹치고, 둘 다 안 보는 영역(대개 예외 처리·재시도·마이그레이션 코드)이 남는다. 병합 리포트의 가치는 총합 숫자가 아니라 이 빈 구역을 보여 주는 데 있다.

CI 게이트 — 전체 커버리지는 게이트로 쓰기 나쁘다

전체 커버리지에 절대 임계값을 걸면 두 가지 문제가 생긴다. 레거시가 큰 저장소에서는 임계값을 넘기기까지 아무도 움직이지 않고, 한번 넘기고 나면 큰 파일 하나만 추가해도 게이트가 깨져 관련 없는 PR이 막힌다. 실무에서 쓰이는 게이트는 세 가지다.

방식규칙성격
절대 임계값전체 라인 85% 이상신규 프로젝트에만 적합
하락 금지(ratchet)직전 기본 브랜치 대비 -0.5%p 이하레거시에서 서서히 올릴 때
변경분(diff) 커버리지이 PR이 추가·수정한 줄의 80% 이상대부분의 팀에 가장 적합

변경분 커버리지는 "새로 쓰는 코드는 테스트와 함께 온다"는 규칙을 기계적으로 강제한다. 레거시를 건드리지 않고도 시간이 지나면 커버리지가 올라간다.

# .github/workflows/coverage.yml (발췌)
- name: 테스트와 커버리지
  run: |
    python -m coverage run --branch -m pytest
    python -m coverage xml
 
- name: 변경분 커버리지 게이트
  run: |
    git fetch --no-tags --depth=1 origin main
    diff-cover coverage.xml \
      --compare-branch=origin/main \
      --fail-under=80 \
      --html-report diff-coverage.html
 
- name: 리포트 보관
  if: always()
  uses: actions/upload-artifact@v4
  with:
    name: coverage-report
    path: |
      htmlcov/
      diff-coverage.html

게이트 설계 자체는 모듈 14(레슨 4)에서 다뤘다. 여기서 더할 것은 하나다 — 게이트를 커버리지 하나로만 걸면 팀은 어설션 없는 테스트로 숫자를 채운다. 실행만 하고 아무것도 검증하지 않는 테스트도 커버리지는 100% 올린다. 이 구멍을 막는 것이 레슨 3의 뮤테이션 테스트다.

실무에서 왜 필요한가

커버리지 리포트를 직접 뽑아 본 적 없는 QA는 회의에서 "커버리지 몇 %인가요"까지만 묻고 끝난다. 리포트를 열어 본 QA는 다른 질문을 한다. "결제 취소 클래스만 branch 43%인데, 환불 실패 분기에 테스트가 없는 것 아닌가요", "이번 PR이 고친 47줄 중 12줄이 미실행인데 그 12줄이 어디인가요". 이 질문은 개발자가 답을 알고 있고 5분이면 고칠 수 있는 질문이라 실제로 고쳐진다.

또 규제 도메인에서는 구조 커버리지가 선택이 아니다. IEC 62304·ISO 26262·DO-178C 계열은 안전 수준에 따라 구문·분기·MC/DC 달성을 요구하고, 달성하지 못한 부분은 "왜 달성할 수 없는지"를 문서로 정당화(justification)해야 한다(모듈 35, 모듈 40). 이때 필요한 것이 리포트 원본과 제외 규칙의 근거다. 도구 설정 파일 자체가 심사 대상이 된다.

채용시장에서의 위치

"품질 게이트 구축 경험"을 요구하는 공고에서 기대하는 것은 SonarQube 대시보드를 켜 봤다는 경험이 아니라, 게이트가 개발 속도를 죽이지 않으면서 작동하게 만든 경험 이다. 전체 커버리지 게이트를 변경분 커버리지로 바꾼 이야기, 제외 규칙을 리뷰 대상으로 만든 이야기가 면접에서 잘 통한다.

실습 과제

과제 1 — 같은 코드, 세 도구 (25분)

아래 함수를 세 언어 중 두 언어로 옮기고, 테스트를 딱 한 개 만 작성한 뒤 각 도구로 리포트를 뽑는다.

def shipping_fee(total: int, is_vip: bool, region: str) -> int:
    if is_vip:
        return 0
    if region == "JEJU" or region == "ISLAND":
        return 5000 if total < 50000 else 3000
    return 0 if total >= 50000 else 3000
def test_vip_is_free():
    assert shipping_fee(10000, True, "SEOUL") == 0

리포트를 열고 다음 표를 채운다.

도구line/statementbranch부분 분기로 표시된 줄

두 도구의 branch 숫자가 다르게 나왔다면 왜 다른지 를 한 문장으로 적는다(힌트: 단축 평가되는 or를 각 도구가 몇 개의 분기로 세는가).

과제 2 — 변경분 커버리지 게이트 만들기 (20분)

자신이 쓰는 저장소 하나에 변경분 커버리지 게이트를 붙인다. 없으면 과제 1의 코드로 작은 저장소를 만든다.

  1. 기본 브랜치에서 커버리지 XML을 생성한다
  2. 함수를 한 개 추가하고 테스트 없이 PR을 만든다
  3. diff-cover(또는 언어별 대응 도구)로 게이트가 실패 하는 것을 확인한다
  4. 테스트를 추가해 게이트가 통과하는 것을 확인한다
  5. 게이트 실패 메시지가 "어느 파일 몇 번 줄이 미실행인지"를 알려 주는지 확인하고, 아니면 리포트 옵션을 추가한다

과제 3 — 제외 규칙 감사 (15분)

현재 팀의 커버리지 설정 파일(.nycrc, .coveragerc, build.gradle, sonar-project.properties 중 하나)을 열어 제외 목록을 전부 적는다. 각 항목에 대해 "로직이 없어서 제외" / "테스트하기 어려워서 제외" / "이유 불명"으로 분류하고, 뒤의 두 범주에 대해 제외를 풀면 커버리지가 몇 %p 떨어지는지 실제로 측정한다. 그 숫자가 팀이 모르고 있던 부채의 크기다.

자가 체크리스트

  • JaCoCo·Istanbul·coverage.py가 각각 무엇을 계측 단위로 삼는지 설명할 수 있다
  • JaCoCo 리포트의 instruction·branch·Cxty 열을 읽고 손댈 클래스를 고를 수 있다
  • coverage.py의 33->36, 44->exit 같은 arc 표기를 해석할 수 있다
  • all: true, source= 설정이 없으면 커버리지가 과대평가되는 이유를 설명할 수 있다
  • 단위·통합·E2E 커버리지를 병합해 하나의 리포트로 만들 수 있다
  • 절대 임계값·하락 금지·변경분 커버리지 게이트의 차이와 적용 상황을 구분할 수 있다
  • 변경분 커버리지 게이트를 CI 워크플로에 직접 붙일 수 있다
  • 커버리지 게이트만으로는 막지 못하는 "어설션 없는 테스트" 문제를 설명할 수 있다
  • 커버리지 제외 규칙의 타당성을 항목별로 판정할 수 있다

흔한 실수

  • 라인 커버리지 하나만 보고 판단한다. 노란색 부분 분기가 실제 구멍이다. 리포트에서 branch 열을 먼저 본다.
  • --branch 나 브랜치 카운터를 켜지 않고 분기 커버리지를 봤다고 믿는다. 리포트에 Branch 열이 있는지부터 확인한다.
  • 테스트가 import 하지 않은 파일을 리포트에서 빠뜨린다. all: true(nyc), source=(coverage.py), classDirectories(JaCoCo)로 전체 대상을 명시한다.
  • 숫자를 올리려고 제외 목록을 늘린다. 제외는 커밋되는 코드이므로 리뷰 대상으로 만들고, 사유를 주석으로 남긴다.
  • 전체 커버리지에 절대 임계값을 걸어 관련 없는 PR을 막는다. 레거시가 있는 저장소에서는 변경분 커버리지가 거의 항상 더 낫다.
  • 커버리지 게이트를 통과했으니 테스트가 충분하다고 결론 낸다. 커버리지는 실행 여부만 센다. 검출력은 레슨 3에서 따로 측정한다.

참고 자료

  • JaCoCo — Coverage Counters — instruction·branch·line·complexity 카운터의 정의
  • JaCoCo — Change History — 지원 Java 클래스 파일 버전과 릴리스 이력(도구 버전 선택 시 원문 확인)
  • Istanbul(istanbul.js) — nyc CLI와 리포터 옵션
  • Vitest — Coverage — v8·istanbul 프로바이더 차이와 임계값 설정
  • coverage.py 문서 — --branch, arc 표기, combine, exclude_lines
  • diff_cover — 변경분 커버리지 리포트 생성 도구
  • 레슨 s1-m03-l07 — 구조 기반 기법: 구문·분기·조건·MC/DC
  • 레슨 s3-m10-l05 — 커버리지 리포트의 함정
  • 레슨 s4-m14-l04 — 품질 게이트 설계

낯선 말 풀이

이 레슨에 나온 약어입니다. 본문에서도 처음 나올 때 점선으로 표시해 두었습니다 (제목과 링크 글자는 그대로 둡니다).

MC/DC — 변형 조건·결정 커버리지
조건이 여러 개 붙은 판단문에서, 각 조건이 혼자서 결과를 바꾸는 경우까지 모두 확인했는지 재는 기준.
JVM — 자바 가상 머신
자바로 만든 프로그램을 돌려 주는 바탕 프로그램.
JS — 자바스크립트
웹 화면을 움직이게 만드는 프로그래밍 언어. 버튼을 눌렀을 때 일어나는 일을 적는다.
E2E — 끝에서 끝까지
화면 한 조각이 아니라 사용자가 밟는 전체 흐름을 처음부터 끝까지 한 번에 돌려 보는 시험.
CI — 지속적 통합
코드를 올릴 때마다 자동으로 합쳐서 빌드하고 시험까지 돌리는 방식.
PR — 풀 리퀘스트
"내가 고친 코드를 본체에 합쳐 달라"고 올리는 요청. 합치기 전에 사람들이 보고 의견을 단다.
QA — 품질보증
만든 것이 쓸 만한지 확인하고, 애초에 덜 망가지게 만드는 방법을 정하는 일. "테스트"는 그 일의 일부다.
IEC — 국제전기기술위원회
전기·전자 분야의 국제 규격을 만드는 단체.
ISO — 국제표준화기구
여러 나라가 모여 "이건 이렇게 하자"를 문서로 정하는 단체. 그 문서 자체를 번호로 부른다.
DO-178C — 항공 소프트웨어 인증 지침
비행기에 들어가는 소프트웨어를 어떻게 만들고 증명해야 하는지 정한 문서.
XML — 확장 표시 언어
제이슨과 목적이 같은, 더 오래된 글자 형식. 꺾쇠 괄호로 이름을 감싼다.
CLI — 명령 줄 인터페이스
검은 화면에 글자로 명령을 적어 넣는 방식. 버튼은 없고 줄마다 명령을 쓴다.

용어집 전체 보기

이 레슨의 실습

실습은 별도 저장소에서 진행합니다. 새 탭으로 열립니다.