테스트의 질을 어떻게 측정하는가 — 커버리지와 뮤테이션 테스트
커버리지는 '그 코드가 실행되었는가'만 알려줄 뿐, '실행 결과가 올바르게 검증되었는가'에는 답하지 않는다. 행·분기·MC/DC라는 강도가 다른 커버리지 종류, 굿하트의 법칙이 낳는 100% 신화라는 함정, 항공기 소프트웨어 DO-178C가 요구하는 MC/DC를 따라간 뒤, 코드에 작은 버그를 실제로 심어 테스트가 감지할 수 있는지 측정하는 '뮤테이션 테스트'가 커버리지의 맹점을 어떻게 보완하는지 살펴본다.
커버리지라는 안심감의 함정
커버리지(coverage)는 테스트 실행 시 소스 코드가 실제로 얼마나 지나갔는지를 측정하는 지표다. “테스트를 작성했다”는 주관적 안심감을 수치라는 객관적 형태로 가시화할 수 있는 점이 지지받는 이유지만, 동시에 “수치만 높으면 품질이 높다”는 오해를 부르기 쉬운 지표이기도 하다. 커버리지가 답할 수 있는 것은 “그 코드가 실행되었는가”뿐이다. “실행된 결과가 올바르게 검증되었는가”에는 전혀 답하지 않는다. 이 비대칭성을 이해하지 못한 채 커버리지 목표를 운용하면, 흔히 숫자만 오르고 품질은 오르지 않는 역설적 결과를 낳는다.
커버리지의 종류
커버리지에는 강도가 다른 여러 종류가 있으며, 아래로 갈수록 요구되는 망라성이 엄격해진다.
| 종류 | 무엇을 측정하는가 | 비고 |
|---|---|---|
| 명령/행 커버리지 | 실행 가능한 문·행이 최소 1회 실행되었는가 | 가장 느슨하고 가장 일반적인 지표 |
| 분기 커버리지 | if·switch 등 각 분기가 최소 1회 지나갔는가 |
행 커버리지보다 엄격. 도달하지 않은 else를 검출 |
| 조건 커버리지 | 논리식 내 각 부분 조건이 참·거짓을 모두 취했는가 | 분기 전체 결과는 묻지 않아 단독으로는 약할 수 있다 |
| MC/DC(조건분기 망라) | 각 조건이 다른 조건을 고정한 채 단독으로 분기 결과를 바꿀 수 있음을 보인다 | N개 조건에 대해 대략 N+1건의 테스트로 충분. 항공 업계 표준에서 요구 |
| 경로 커버리지 | 제어 흐름상의 모든 실행 경로를 망라 | 루프가 있으면 경로 수가 지수적으로 폭발해 현실적으로 거의 달성 불가능 |
이들은 포함 관계에 있어 MC/DC를 만족하면 분기 커버리지도 자동으로 만족하지만, 역은 성립하지 않는다.
MC/DC가 테스트 수를 줄일 수 있는 이유
세 조건 A·B·C로 이루어진 판정 A && B && C를 생각해 보자. 모든 조합을 망라하는 복합조건 망라(MCC) 는 2^3 = 8가지 테스트를 요구하지만, MC/DC는 “각 조건이 다른 조건을 고정한 상태에서 단독으로 판정 결과를 바꿀 수 있음을 보이는 것”만 요구하므로 조건 수 N에 대해 대략 N+1건(이 경우 4건 정도)이면 충분하다. 조건이 10개라면 MCC는 1024가지, MC/DC는 약 11건이면 된다. DO-178C 같은 안전 크리티컬 규격이 이론상 가장 엄밀한 경로 커버리지나 MCC가 아니라 MC/DC를 채택하는 것은, 바로 이 현실적인 테스트 수와 검증 비용의 균형 때문이다.
주요 도구
| 도구 | 대상 언어 | 특징 |
|---|---|---|
| gcov / lcov | C / C++ | gcov는 GCC 부속 계측 도구, lcov가 HTML 리포트를 생성하는 정석 조합 |
| Istanbul / nyc | JavaScript / TypeScript | Istanbul이 코드 계측 엔진, Jest의 커버리지도 Istanbul 계열 구현을 이용 |
| JaCoCo | Java | 바이트코드 계측 방식. Maven/Gradle과 통합 |
| coverage.py | Python | pytest-cov를 통해 pytest와 연동하는 것이 정석 |
coverage.py를 pytest-cov로 실행하면 다음과 같은 리포트를 얻는다(수치는 예시).
Name Stmts Miss Branch BrPart Cover
------------------------------------------------------
orders/pricing.py 48 6 12 3 82%
orders/discount.py 30 0 8 0 100%
------------------------------------------------------
TOTAL 78 6 20 3 89%
BrPart 열은 부분적으로만 지나간 분기(if는 지났지만 else는 지나지 않은 등)를 나타낸다. 행 커버리지만 보고 있으면 이 누락을 알아챌 수 없다.
커버리지의 함정
굿하트의 법칙 — “measure가 target이 되는 순간, 그 measure는 좋은 지표가 아니게 된다” — 은 커버리지 운용을 정면으로 타격한다. “커버리지 80% 이상을 머지 조건으로 한다”는 규칙을 두면, 개발자는 그 수치를 채우는 것 자체를 목적화하기 시작한다.
// 행 커버리지는 채우지만, 아무것도 검증하지 않는 테스트
test("processOrder runs without crashing", () => {
processOrder(sampleOrder); // assert가 하나도 없다
});
100% 커버리지 신화도 뿌리 깊다. 100% 행 커버리지는 “모든 행이 최소 1회 실행되었다”는 것만 의미하며, 어서션을 동반하지 않는 테스트로도 달성할 수 있다. 커버리지는 “테스트가 존재하지 않음을 검출하는” 하한의 안전망이지, “테스트가 올바름을 보장하는” 상한의 증명이 아니다.
다음 함수를 생각해 보자.
def apply_discount(price, is_member):
if is_member:
price = price * 0.9
return price
apply_discount(1000, True)라는 1건의 테스트만으로 if 블록을 포함한 전체 행이 실행되어 행 커버리지는 100%에 도달한다. 그러나 is_member=False 경로는 한 번도 지나지 않았으며, 분기 커버리지로 볼 때 비로소 이 누락이 가시화된다. 게다가 구조적 누락은 0%에서 70~80%까지는 저렴하게 찾을 수 있지만, 80%에서 100%에 가까워질수록 대상은 사소한 getter/setter나 도달하기 어려운 방어적 오류 처리가 되기 쉬워, 얻을 수 있는 안전성 향상은 급속히 체감한다.
안전 크리티컬 분야에서의 커버리지 요구
DO-178C(항공기 탑재 소프트웨어의 인증 가이드라인, RTCA/EUROCAE 책정)는 고장의 중대성에 따라 설계 보증 수준(DAL)을 A부터 E까지 구분하고, 수준별로 요구되는 구조 커버리지를 정하고 있다.
| DAL 수준 | 상정되는 고장 영향 | 요구되는 구조 커버리지 |
|---|---|---|
| 수준 A | 기체의 지속적 안전 비행·착륙을 방해할 수 있는 치명적 고장 | MC/DC |
| 수준 B | 중대한 고장 | 분기 커버리지 |
| 수준 C | 경미한 고장 | 명령 커버리지 |
| 수준 D/E | 사소함/안전성과 무관 | 명시적 요구 없음/대상 외 |
ISO 26262(자동차 기능 안전 규격)도 자동차 안전 무결성 수준(ASIL)에 따라 커버리지 권장도를 달리한다. ASIL A/B에서는 명령 커버리지, 더 높은 ASIL C/D에서는 MC/DC의 충족이 요구된다. DO-178C가 “필수 요구”로 명기하는 데 비해, ISO 26262는 “권장” 단계의 등급이 많아 딱딱한 의무 규정이라기보다 가이드라인에 가깝다. 이들 규격은 커버리지를 “품질의 증명”으로서가 아니라 “테스트되지 않은 위험한 코드가 남아 있지 않다는 하한 보증”으로 자리매김한다.
건전한 목표 설정의 사고방식
- 차분 커버리지: “신규·변경 행의 커버리지”를 임계값으로 삼아, 기존의 낮은 커버리지 자산을 무리하게 끌어올리지 않고 “더 이상 악화시키지 않는 제동 장치”로 기능하게 한다
- 리스크 기반의 농담: 결제·인증처럼 영향이 큰 모듈에는 높은 기준을 부과하고, 저리스크 영역은 완화한다
- 암 검진의 비유: 낮은 커버리지는 “정밀 검사해야 할 곳이 있다”는 신호일 뿐, 높은 커버리지가 그대로 “건강”을 의미하지는 않는다
- 어서션의 내용은 리뷰로 본다: 도구로는 검출할 수 없는 공허한 테스트는 사람이 간파할 수밖에 없다
이러한 한계를 보완하는 기법이, 커버리지의 “양”이 아니라 검증의 “질”을 직접 측정하는 뮤테이션 테스트다.
뮤테이션 테스트의 등장
뮤테이션 테스트는 소스 코드를 기계적으로 조금만 변이시킨 “뮤턴트(mutant)“를 대량으로 생성하고, 기존 테스트 스위트가 그 변이를 검출할 수 있는지 하나씩 확인하는 기법이다. 검출하면 그 뮤턴트는 “죽었다(killed)”, 못 하면 “살아남았다(survived)“고 판정된다. 발상의 원점은 단순한 물음에 있다 — “행 커버리지가 100%여도 그 테스트는 정말 버그를 찾아낼 수 있는가.”
이론의 기원은 오래되어, Richard Lipton이 학생 시절인 1971년에 착상했다고 하며, Richard A. DeMillo, Richard J. Lipton, Frederick G. Sayward에 의한 1978년 논문 “Hints on Test Data Selection”에서 체계화되었다. 이 논문은 두 가지 가설을 제시한다.
- 유능한 프로그래머 가설: 숙련된 프로그래머의 버그는 단순한 구문 변경으로 표현할 수 있는 작은 것이 대부분이다
- 커플링 효과: 단순한 변이를 검출할 수 있는 테스트는, 그것들이 조합된 복잡한 결함도 암묵적으로 검출한다
뮤테이션 연산자의 예
| 카테고리 | 변이의 예 |
|---|---|
| 조건 반전 | if (a == b) → if (a != b) |
| 경계값 변경 | a < b → a <= b |
| 산술 연산자 치환 | a + b → a - b |
| 논리 연산자 치환 | a && b → a || b |
| 반환값 변경 | return true; → return false; |
| 문 삭제 | 한 문장 통째로 삭제 |
public boolean isAdult(int age) {
return age >= 18;
}
경계값 변경 연산자는 age >= 18을 age > 18로 변이시킨다. 테스트가 isAdult(18)을 검증하지 않으면 이 뮤턴트는 살아남는다. 행 커버리지만으로는 결코 간파할 수 없는 종류의 테스트 구멍이 여기서 가시화된다.
등가 뮤턴트 문제와 계산 비용
구문은 바뀌었지만 모든 입력에 대해 원래 프로그램과 완전히 같은 동작을 하는 “등가 뮤턴트“는 원리적으로 결코 죽일 수 없다. 어떤 변이가 등가인지 일반적으로 자동 판정하는 것은 결정 불가능하며, 실무에서는 살아남은 뮤턴트를 사람이 리뷰해 분류하는 작업이 발생한다.
뮤테이션 테스트의 소박한 구현은 “뮤턴트 수 × 테스트 스위트 실행 시간”의 비용이 든다. 실무 도구는 다음과 같은 최적화로 이를 완화한다.
- 커버리지 우선 실행: 그 뮤턴트 위치에 행 커버리지를 갖는 테스트만 돌린다. Java의 PIT(Pitest)가 채택
- Mutation Switching: 여러 뮤턴트를 하나의 바이너리에 묶어 실행 시 플래그로 전환한다. Stryker.NET이나 stryker4s가 채택
- 선택적 뮤테이션: 경험적으로 효과가 높은 일부 연산자에만 좁힌다
- 차분 기반: 변경된 행만 대상으로 한다. Google은 “State of Mutation Testing at Google”에서 코드 리뷰 시 차분에 뮤테이션을 적용해 리뷰어에게 제시하는 구조를 보고한다
- 병렬 실행: 뮤턴트별 실행은 독립적이므로 CI 워커를 늘려 병렬화하기 쉽다
주요 도구와 뮤테이션 스코어 계산 예
| 도구 | 대상 언어 | 특징 |
|---|---|---|
| PIT(Pitest) | Java | 바이트코드 수준에서 변이를 주입. Java 진영의 사실상 표준 |
| Stryker | JavaScript/TypeScript, C#, Scala | Mutation Switching으로 고속화 |
| mutmut | Python | 소스의 정형을 유지한 채 변이를 적용 |
뮤테이션 스코어는 “죽인 뮤턴트 수 ÷ (전체 뮤턴트 수 − 등가 뮤턴트 수)“로 산출된다. 어떤 모듈에서 100개의 뮤턴트를 생성해 72개가 죽고, 20개가 살아남고, 8개가 등가 뮤턴트로 판명된 경우, 스코어는 72 ÷ (100 − 8) = 72 ÷ 92 ≈ 78.3%가 된다. 등가 뮤턴트를 제외하지 않고 단순히 72 ÷ 100 = 72%로 하면, 원리적으로 죽일 수 없는 만큼 스코어가 부당하게 낮게 나온다.
무엇을 검출할 수 없는가, 실무에서의 운용
뮤테이션 테스트는 만능이 아니다. “작성된 코드”를 변형하는 것이며, “본래 작성되어야 하지만 작성되지 않은 처리”의 누락은 검출할 수 없다. 경합 상태 같은 타이밍 의존 버그에도 약하다. 이들은 프로퍼티 기반 테스트 같은 다른 기법이 보완하는 영역이다.
계산 비용이 높은 탓에, 실무에서는 모든 커밋에 대한 필수 게이트로 쓰기보다, 야간·주간 배치로 정기 실행, 차분 기반으로 PR 단위로 좁혀 리뷰어에게 제시, 결제 로직 등 중요 모듈로 좁히기, 스코어에 지나치게 엄격한 임계값을 부과하지 않는 운용이 현실적이다.
정리
커버리지는 “실행의 양”을 측정하고, 뮤테이션 테스트는 “검증의 질”을 측정한다 — 이 대비로 정리하면 양자의 관계를 이해하기 쉽다. 행·분기·조건·MC/DC·경로라는 단계 중 어느 것을 선택할지는 비용과 보장하고 싶은 강도의 트레이드오프이며, 안전 크리티컬 분야에서는 그 강도가 규격으로 명문화되어 있다. 일반 개발에서는 커버리지를 목적화하지 않고 차분 커버리지에 의한 제동과 리스크 기반의 농담을 두며, 뮤테이션 테스트에 의한 질적 보완을 조합하는 것이, 굿하트의 법칙의 함정을 피하는 현실적인 길이 된다. 단위 테스트의 설계 원칙에 대해서는 좋은 단위 테스트란 무엇인가를, 다음 글에서는 테스트의 신뢰성 자체를 좀먹는 “플레이키 테스트”를 다룬다.
참고문헌
- RTCA/EUROCAE, DO-178C “Software Considerations in Airborne Systems and Equipment Certification”
- ISO 26262 (Road vehicles — Functional safety) Part 6
- Richard A. DeMillo, Richard J. Lipton, Frederick G. Sayward, “Hints on Test Data Selection: Help for the Practicing Programmer” (1978, IEEE Computer)
- Henry Coles 외, “PIT: a practical mutation testing tool for Java” (ISSTA 2016)
- Goran Petrovic, Marko Ivankovic, “State of Mutation Testing at Google” (Google Research)
- Yue Jia, Mark Harman, “An Analysis and Survey of the Development of Mutation Testing”