뮤테이션 테스트로 테스트 스위트의 결함 탐지력 검증하기
뮤테이션 테스트의 뮤턴트 판정과 점수 산정, 경험기반 테스트 선별, 품질 게이트 및 PIT 활용 방식을 정리한다.
2026-08-15 · 최초 발행 2025-12-15
테스트가 결함을 잡아낼 수 있는지 확인하는 방법
코드 커버리지만으로는 테스트가 실제 결함을 찾아낼 수 있는지 충분히 판단하기 어렵다. 커버리지는 어떤 코드가 실행됐는지를 보여주는 양적 측정인 반면, 뮤테이션 테스트는 코드 변경을 테스트가 감지할 수 있는지를 확인하는 질적 측정이다. 같은 커버리지라도 조건과 계산의 작은 변화에 반응하지 못하면 결함 탐지력은 다를 수 있다.
뮤테이션 테스트는 정상 프로그램의 원시부호를 뮤테이션 오퍼레이터로 변형해 뮤턴트(Mutant)를 만들고, 기존 테스트가 원본과 다른 결과를 감지하는지 확인한다. 테스트 자체를 검증하는 메타 테스팅 기법에 가깝다.
테스트가 차이를 찾아내 뮤턴트를 사살(Kill)하면 해당 테스트는 효과적인 것으로 본다. 차이를 만들지 못해 뮤턴트가 생존(Live)하면 테스트를 보강할 대상이 된다. 작은 결함까지 드러내며, 코드의 모호한 부분과 테스트 공백을 찾는 데 쓴다.
여기서 동등 뮤턴트(Equivalent Mutant)는 구문은 달라도 기능적으로 같아 어떤 입력으로도 차이를 만들지 않는 변형이다. 이는 제거 대상이며, 라이브 뮤턴트와 구분해야 한다.
뮤테이션 점수는 다음 식으로 계산한다.
MS(P, T) = Dead Mutant 수 / (전체 Mutant 수 − Equivalent Mutant 수)
이 점수는 테스트 스위트의 결함 탐지력(adequacy)을 근사적으로 보여준다.
변형 규칙과 뮤턴트 판정
뮤테이션 오퍼레이터의 구성은 실행 비용과 검증 품질에 직접 영향을 준다. 상수 대치, 변수 대치, 상수와 변수의 대치가 기본 유형이며, 실무에서는 산술·비교·논리 연산자 대치, 경계값(>, >=) 치환, 조건 분기 토글, 반환값 변형도 함께 쓴다.
예를 들어 산술 연산자는 +에서 -로, 관계 연산자는 >에서 >=로, 논리 연산자는 &&에서 ||로 바꿀 수 있다. 상수 0을 1로, true를 false로 바꾸거나 변수 값을 증감·고정값으로 대체하는 경우도 있다. 조건식을 반전하거나 case, for, while의 조건을 바꾸는 변이도 여기에 포함된다.
언어와 도메인에 맞춰 산술·비교·논리·상수·변수 대치 오퍼레이터에 Null 핸들링, 동시성, 경계 플래그 같은 도메인 특화 규칙을 조합할 수 있다.
뮤턴트는 생성된 뒤 테스트 실행을 거쳐 Dead, Live, Equivalent로 분류된다. Equivalent 탐지는 자동화하기 어렵기 때문에 정적 분석, 제약 해결, 컴파일러 최적화 힌트로 후보를 줄이는 접근을 사용한다.
입력은 오리지널 소스, 테스트 스위트, 오퍼레이터 세트다. 처리 과정은 뮤턴트 생성, 테스트 실행, 결과 비교, 동등·라이브·킬 판정으로 이어진다. 결과물로는 뮤테이션 점수, Live 목록, 동등 후보, 테스트 보강 백로그를 얻는다.
무한 루프를 유발한 뮤턴트는 타임아웃으로 격리하고, 불안정 테스트는 재시도하거나 격리한다. 빌드에 실패한 뮤턴트는 무효화한다.
할인 계산에서 드러나는 경계값 공백
다음 Java 메소드는 가격과 프리미엄 고객 여부에 따라 할인을 계산한다.
public int calculateDiscount(int price, boolean isPremiumCustomer) {
if (price > 1000 && isPremiumCustomer) {
return price / 10; // 10% 할인
} else if (price > 1000) {
return price / 20; // 5% 할인
}
return 0; // 할인 없음
}
이 코드에 적용할 수 있는 뮤턴트에는 price > 1000을 price >= 1000으로 바꾸는 조건 변경, &&를 ||로 바꾸는 논리 연산자 변경, /10을 /5로 바꾸는 산술 연산자 변경이 있다.
기존 테스트가 아래와 같다면 문제가 남는다.
@Test
void testPremiumCustomerDiscount() {
assertEquals(100, calculateDiscount(1000, true)); // 버그: 1000은 할인 대상 아님
}
@Test
void testRegularCustomerDiscount() {
assertEquals(100, calculateDiscount(2000, false));
}
이 테스트는 변이체 2와 3을 죽이지 못한다. 또한 첫 번째 테스트는 원본 코드에서 1000이 할인 대상이 아니라는 사실도 검증하지 못한다. 경계값과 고객 조건을 분리해 확인하도록 테스트를 바꾸면 뮤턴트가 드러낸 공백을 메울 수 있다.
@Test
void testPremiumCustomerDiscount() {
assertEquals(0, calculateDiscount(1000, true)); // 1000은 할인 없음
assertEquals(200, calculateDiscount(2000, true)); // 200 할인
}
@Test
void testRegularCustomerDiscount() {
assertEquals(0, calculateDiscount(1000, false)); // 1000은 할인 없음
assertEquals(100, calculateDiscount(2000, false)); // 100 할인
}
@Test
void testNoDiscount() {
assertEquals(0, calculateDiscount(500, true));
assertEquals(0, calculateDiscount(500, false));
}
고위험 코드와 변경 영역부터 검사하기
모든 테스트와 뮤턴트를 같은 우선순위로 처리할 필요는 없다. 결함이 자주 발생한 모듈, 최근 변경된 코드, 복잡도 지표(Cyclomatic), 커버리지를 함께 고려하면 실행 순서를 정할 수 있다. 고가치 경로와 경계 입력을 먼저 실행하면 Live Mutant를 빠르게 줄일 수 있다.
입력 데이터로는 변경 이력, 라인·분기 커버리지, 복잡도, 결함 로그, 코드 소유권을 사용한다. 최근 변경·결함 다발 모듈을 먼저 두고, 고복잡도 경로와 경계값·예외 경로를 뒤따르게 하는 방식이다.
실행은 고우선순위 세트로 빠르게 Mutant Kill을 시도한 뒤, Live Mutant를 분석해 경계값 입력과 조합 분해를 반영한 테스트를 추가한다. 뮤테이션 점수 개선이 둔화되면 오퍼레이터 범위를 넓히거나 샘플을 늘린다.
품질 게이트와 실행 비용의 균형
모듈과 라인별로 뮤테이션 점수 목표를 정하고 CI 파이프라인에서 이를 품질 게이트로 운영할 수 있다. 핵심 모듈은 80% 이상, 일반 모듈은 60~70%를 목표로 둘 수 있으며, 동등 뮤턴트는 점수 산정에서 제외한다.
전체 뮤테이션 테스트는 야간 빌드에 넣고, 변경된 코드에만 선택적 뮤테이션 테스트를 적용하거나 주요 코어 모듈에 범위를 한정할 수 있다. 실행 시간을 줄이기 위한 방법도 있다.
- Selective Mutation은 효과가 큰 핵심 오퍼레이터만 사용한다. 속도는 높아지지만 뮤테이션 점수가 달라질 수 있다.
- Incremental/Targeted Mutation은 변경된 라인이나 파일만 변형한다. 최신 변경 검증에는 적합하지만 전체 품질 지표가 편향될 수 있다.
- Mutant Sampling은 무작위 표본으로 빠르게 피드백을 받는 대신 통계적 오차를 허용해야 한다.
- Weak Mutation/Schema는 부분 평가나 런타임 토글로 속도를 높이지만 정밀도 손실 가능성이 있다.
- 정적 분석, 제약 해결, 심볼릭 실행으로 Equivalent 후보를 줄일 수 있지만 계산 비용은 증가한다.
테스트 실행 병렬화, Mutant Schemata의 런타임 토글, 타임아웃 처리, 플레이키(Flaky) 테스트 격리도 비용 최적화에 포함된다.
비버깅과 다른 점
| 구분 | 성능 | 확장성 | 일관성 | 안정성 | 운영 편의 |
|---|---|---|---|---|---|
| 뮤테이션 테스트 | 중간: 테스트 수·오퍼레이터 수에 비례. 최적화로 개선 | 높음: 선택적·증분 실행 지원 | 높음: 동일 규칙으로 변형·평가 일관 | 높음: 자동화·재현 용이 | 높음: 도구 성숙(PIT/Stryker 등) |
| 비버깅 | 낮음~중간: 인위 결함 설계·삽입 비용 큼 | 낮음: 결함 시나리오 확장에 인력 의존 | 중간: 결함 설계 품질에 따라 편차 | 중간: 결함 난이도·분포에 민감 | 낮음: 표준 도구 부족, 관리 비용 큼 |
뮤테이션 테스트는 테스트 케이스의 적합성과 신뢰성 확보에 초점을 둔다. 반면 비버깅(Be-bugging, Fault Seeding)은 의도적으로 오류를 삽입해 잔존 오류를 추정하며, 이를 위해 수학적 추정 모델이 필요하다.
점수 해석에 필요한 기준
전체 뮤턴트가 120개이고, 이 가운데 동등 뮤턴트가 10개, Dead가 96개, Live가 14개라고 가정한다.
유효 모수는 120 − 10 = 110이다. 따라서 뮤테이션 점수는 96 / 110 ≈ 0.8727, 즉 87.3%가 된다.
점수 하나만으로 테스트 품질을 단정할 수는 없다. 코드 커버리지는 실행된 코드의 범위를 확인하고, 뮤테이션 점수는 그 범위에서 테스트가 변경을 감지하는지를 확인한다. 동등 뮤턴트는 자동 식별이 어렵고 점수 해석에도 영향을 준다.
점수 목표는 Equivalent 처리 성숙도와 성능 예산에 따라 단계적으로 높일 수 있다. 핵심 모듈에는 80% 이상을 권장하고, 일반 모듈은 60~70%를 현실적인 시작점으로 둘 수 있다.
언어별 도구 선택과 PIT 실행
Java에서는 PIT가 널리 쓰이며 Maven·Gradle 통합과 HTML 리포트 생성을 지원한다. JavaScript와 TypeScript 환경에서는 Stryker를 사용하며 React, Angular, Vue 등의 프레임워크를 지원한다. Python에서는 pytest 등과 통합할 수 있는 mutmut이 있고, C/C++에서는 LLVM 기반의 Mull로 저수준 코드를 분석할 수 있다.
PIT(Java), Stryker(JS/TS), Mutmut(Python)은 CI에 연결해 모듈별 최소 뮤테이션 점수를 적용할 수 있다. 머지 전에 품질을 검증하면 회귀 결함을 사전에 차단하는 데 활용할 수 있다.
다음 예시는 JDK 17+, Maven 3.8+, JUnit 5, 소스와 테스트 분리를 전제로 한다.
// src/main/java/example/Calculator.java
package example;
public class Calculator {
public int add(int a, int b) {
return a + b; // PIT가 산술 연산 치환 등으로 뮤턴트 생성
}
}
// src/test/java/example/CalculatorTest.java
package example;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
class CalculatorTest {
@Test
void add_shouldHandleZeroAndNegatives() {
assertEquals(5, new Calculator().add(2, 3));
assertEquals(0, new Calculator().add(0, 0));
assertEquals(-1, new Calculator().add(2, -3));
}
}
<!-- pom.xml (요지) -->
<build>
<plugins>
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.15.0</version>
<configuration>
<targetClasses>
<param>example.*</param>
</targetClasses>
<targetTests>
<param>example.*</param>
</targetTests>
<threads>4</threads>
<mutationThreshold>80</mutationThreshold>
<timeoutConst>4000</timeoutConst>
</configuration>
</plugin>
</plugins>
</build>
Maven 프로젝트에서 PIT를 설정하는 다른 예시는 다음과 같다.
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.7.0</version>
<configuration>
<targetClasses>
<param>com.example.project.*</param>
</targetClasses>
<targetTests>
<param>com.example.project.*Test</param>
</targetTests>
</configuration>
</plugin>
실행 명령은 mvn -q -DskipTests=false org.pitest:pitest-maven:mutationCoverage다. 다음 명령으로도 실행할 수 있다.
mvn org.pitest:pitest-maven:mutationCoverage
보고서에서는 Killed, Survived, No Coverage, Timed Out 상태를 확인하고, Live Mutant가 남은 라인에는 경계값과 예외 입력을 추가한 테스트를 설계한다. TDD와 함께 사용할 때는 Red, Green, Refactor 흐름 뒤에 뮤테이션 테스트 실행과 분석을 추가할 수 있으며, 살아남은 뮤턴트가 있으면 필요한 테스트 케이스를 보완한다.
리팩터링과 회귀 검증에서 얻는 효과
레거시 코드를 리팩터링하기 전에 Live Mutant가 많이 남아 있다면, 먼저 결함 탐지 테스트를 보강해 안전망을 만들 수 있다. 이후 구조 변경을 진행하면 리스크를 줄일 수 있다.
변경 영향 영역에 증분 뮤테이션을 적용해 동일한 뮤테이션 점수를 달성한 경우, 테스트 시간이 3060% 절감된 사례가 있다. 코드 커버리지가 같은 조건에서는 결함 검출률이 1030%p 개선될 수 있으며, 팀과 도메인에 따라 차이가 있다.
대형 금융 회사의 결제 시스템 사례에서는 테스트 커버리지가 95%였지만 변이 점수는 68%에 그쳤다. 변이 테스트는 금액 계산의 경계값 테스트 부족, 네트워크 오류와 타임아웃에 대한 예외 처리 테스트 미흡, 할인 정책과 승인 조건 같은 조건부 로직 검증 부족을 드러냈다. 이후 테스트 케이스를 개선해 변이 점수를 85%까지 높이고 실제 운영에서 발생할 수 있는 오류를 사전에 방지했다.
ISO 26262와 IEC 61508 등 고신뢰 요구 시스템에서는 적합성 근거로 활용할 수 있다. 경계와 예외 경로를 강화하면 잔존 결함 리스크를 정량화하는 데도 도움이 된다. 테스트 스위트의 실효성이 드러나고, 애매한 요구사항과 경계 조건이 노출돼 리뷰 품질을 높일 수 있다.