개념
도구마다 "무엇을 세는가"가 다르다
모듈 3(레슨 7)에서 구문·분기·조건·MC/DC 커버리지를 손으로 계산했고, 모듈 10(레슨 5)에서 라인 커버리지가 높아도 분기가 비어 있을 수 있다는 것을 봤다. 실무에서 만나는 숫자는 손계산이 아니라 도구가 만든다. 그런데 도구마다 계측 지점이 다르기 때문에, 같은 코드·같은 테스트라도 숫자가 다르게 나온다.
| 도구 | 대상 | 계측 방식 | 기본 카운터 |
|---|---|---|---|
| JaCoCo | JVM 바이트코드 | 실행 시 에이전트가 클래스 로딩 중 프로브 삽입 | 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.py | Python | 인터프리터 추적 훅(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.htmlexclude를 어디까지 쓸 것인지가 첫 번째 정직성 시험이다. 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->exit33->36 은 "33번 줄에서 36번 줄로 가는 분기가 한 번도 실행되지 않았다"는 뜻이고, 44->exit 은 "44번 줄의 조건이 거짓이어서 함수를 빠져나가는 경로가 없었다"는 뜻이다. 줄 번호
하나가 아니라 화살표(arc)를 읽는 것이 coverage.py 리포트를 제대로 읽는 법이다.
리포트에서 실제로 봐야 할 것
숫자 하나를 보지 말고 세 가지를 본다.
- 빨간 줄의 목록 — 미실행 코드가 어떤 성격인지. 에러 처리 분기만 빨갛다면 예외 시나리오 테스트가 없는 것이다
- 노란 줄(부분 분기) — 라인 커버리지가 숨기는 곳. 여기가 진짜 구멍이다
- 커버리지가 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 대시보드를 켜 봤다는 경험이 아니라, 게이트가 개발 속도를 죽이지 않으면서 작동하게 만든 경험 이다. 전체 커버리지 게이트를 변경분 커버리지로 바꾼 이야기, 제외 규칙을 리뷰 대상으로 만든 이야기가 면접에서 잘 통한다.