테스트는 통과한다. 코드는 여전히 틀렸다.
라인 커버리지는 100%다. 모든 분기를 커버한다. 모든 함수를 호출한다. 그런데 누군가 가격 책정 로직에서 +를 -로 바꾸고 테스트를 돌리면, 테스트가 모두 통과한다.
이것은 이론적인 문제가 아니다. 테스트가 코드를 실행하지만 실제로는 동작을 검증하지 않을 때 벌어지는 일이다. 커버리지는 어떤 라인이 실행되는지 측정할 뿐, 어떤 출력이 검사되는지는 측정하지 않는다. 뮤테이션 테스트는 의도적으로 작은 버그를 주입하고 테스트가 이를 잡아내는지 확인함으로써 이 간극을 메운다.
Rust 팀이 고민해야 할 질문은 뮤테이션 테스트가 좋은 아이디어인지가 아니다. 생태계에서 가장 주목받는 도구인 cargo-mutants가 Rust의 컴파일 시간과 타입 시스템을 고려할 때 실용적인지가 문제다. 답은 ‘그렇다’이지만, 중요한 전제조건이 따른다.
뮤테이션 테스트가 실제로 하는 일
뮤테이션 테스트는 개념적으로 단순하다. 도구가 소스 코드에 미세한 변경을 가하고, 테스트 스위트를 실행한 뒤, 뭔가 실패하는지 확인한다.
테스트 스위트가 실패하면 mutant는 “killed”된다. 이것이 원하는 결과다. 테스트가 버그를 알아챘다는 뜻이다.
테스트 스위트가 통과하면 mutant는 “survived”한다. 이는 테스트가 변형된 코드를 실행하면서도 뭔가 잘못됐음을 눈치채지 못했다는 의미다. 약한 테스트가 있는 것이다.
일반적인 mutation으로는 산술 연산자 교체(+가 -가 됨), 비교 연산자 스왑(>가 >=가 됨), 불리언 리터럴 교체(true가 false가 됨), 그리고 반환값이 있는 함수 호출 삭제 등이 있다. 각 변경은 사람이 봤을 때 버그로 인식할 만큼 작다. 테스트 스위트 역시 이를 인식해야 한다.
cargo-mutants가 Rust 코드에서 작동하는 방식
cargo-mutants는 Rust 전용으로 만들어진 뮤테이션 테스트 도구다. 테스트에 어노테이션을 달거나 빌드 시스템을 변경할 필요가 없다. 설치하고 실행하면 된다.
cargo install cargo-mutants
cargo mutants
이 도구는 소스 파일을 스캔하고, AST에 변환 규칙을 적용하여 mutant를 생성한 뒤, 각각에 대해 cargo test를 실행한다. 살아남은 mutant를 추적하고 보고서를 출력한다.
다음은 견고해 보이지만 실제로는 그렇지 않은 테스트를 가진 함수다:
pub fn apply_discount(price: f64, rate: f64) -> f64 {
price * (1.0 - rate)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_apply_discount() {
let result = apply_discount(100.0, 0.2);
// 함수를 실행했다. 커버리지는 100%다.
// 하지만 결과를 assertion하지 않았다.
}
}
cargo mutants는 *를 /로 바꾸거나 1.0 - rate를 1.0 + rate로 교체하는 mutant를 생성할 것이다. 테스트는 result를 점검하지 않기 때문에 여전히 통과할 것이다. 살아남은 mutant가 문제를 지적한다.
mutant를 죽이는 실제 테스트는 다음과 같다:
#[test]
fn test_apply_discount() {
assert_eq!(apply_discount(100.0, 0.2), 80.0);
assert_eq!(apply_discount(50.0, 0.0), 50.0);
}
이제 모든 산술 mutant는 assertions가 잘못된 출력을 잡아내기 때문에 실패한다.
출력 결과는 어떤 모습인가
cargo mutants를 실행하면 요약 결과를 얻는다:
Found 42 mutants
Killed 38 mutants
Missed 4 mutants
Timeout 0 mutants
Unviable 0 mutants
Missed mutant는 살아남은 mutant다. cargo mutants는 각각을 diff와 파일 경로와 함께 mutants.out/에 기록한다. diff를 읽고 빠진 assertion을 추가하면 된다.
Timeout은 mutant가 무한 루프를 일으킬 때 발생한다. cargo-mutants는 이를 감지하여 timeout으로 killed로 표시하며, 이는 성공으로 간주된다.
Unviable mutant는 컴파일되지 않는 변경이다. Rust의 타입 시스템이 테스트가 실행되기 전에 이를 거부한다.
Rust의 타입 시스템은 양날의 검이다
JavaScript나 Python에서는 뮤테이션 테스트 도구가 거의 모든 연산자를 교체해도 코드가 여전히 실행된다. 단지 잘못된 결과를 낼 뿐이다. Rust에서는 많은 mutation이 테스트가 실행되기 전에 컴파일러가 잡아낸다.
부호 없는 정수에서 +를 -로 바꾸면 오버플로가 날 수 있지만, 코드는 컴파일된다. 제네릭 컨텍스트에서 >를 <로 바꾸면, 트레이트 바운드가 해당 비교를 지원하지 않을 경우 컴파일러가 거부할 수 있다. 호출자가 기대하는 반환값이 있는 함수 호출을 삭제하면 컴파일 에러가 난다.
이는 cargo-mutants가 다른 언어의 동등한 도구보다 viable한 mutant를 적게 생성한다는 뜻이다. Python 프로젝트는 한 모듈에 200개의 mutant를 볼 수도 있다. Rust 프로젝트는 40개 정도일 수 있다. 실제로 컴파일되는 mutant는 프로덕션에 침투할 수 있는 것들이다. 타입 시스템이 노이즈를 걸러낸다.
대신 컴파일 시간이 트레이드오프다. 모든 viable mutant는 재빌드를 유발한다. 테스트 스위트가 5분 걸리는 프로젝트가 cargo mutants를 한 시간 동안 돌릴 수도 있다.
컴파일 시간 비용은 실재한다
이것이 팀들이 망설이는 주된 이유다. 뮤테이션 테스트는 이론상으로는 당연하게 병렬화된다. 각 mutant는 독립적이다. 하지만 실제로 Rust의 빌드 시스템은 같은 소스 트리에 대한 수십 개의 컴파일러 호출을 깔끔하게 병렬화하지 못한다.
cargo-mutants에는 --jobs 플래그가 있지만, 디스크 I/O와 crate graph lock이 병목이 된다. 2코어짜리 일반적인 CI runner에서는 작업 확장성이 떨어진다.
완화책이 있다. 모든 mutant마다 소스 트리를 복사하지 않도록 --in-place를 사용하라. 특정 모듈만 타겟팅하려면 --file이나 --exclude를 사용하라. 뮤테이션 테스트는 매 푸시가 아닌 매일밤이나 매주 실행하라.
cargo-mutants가 놓치는 것
어떤 뮤테이션 테스트 도구도 모든 것을 잡아내지는 못한다. cargo-mutants에는 알아야 할 구체적인 한계가 있다.
매크로 확장은 변형하지 않는다. 핵심 로직이 매크로 안에 있다면, 도구는 호출을 볼 뿐 생성된 코드는 볼 수 없다.
의미적 동등성을 이해하지 못한다. 어떤 mutant는 동작이 다르지만 모든 유효한 입력에 대해 여전히 정확한 결과를 낼 수 있다. 중복된 + 0은 테스트가 신경 쓰지 않기 때문에 살아남을 수 있다. 비록 이 mutation이 실제 버그는 아니지만 말이다. 이런 것들은 수동으로 분류해야 한다.
뮤테이션 테스트가 비용을 감당할 가치가 있는 때
cargo mutants를 매 커밋마다 돌릴 필요는 없다. 테스트 스위트가 커져서 자신의 assertion을 더 이상 신뢰하지 못할 때 필요하다.
중요한 모듈의 커버리지는 높지만 여전히 버그가 출시된 경우, 또는 리팩토링이 로직을 미묘하게 바꾸고 assertion이 충분히 빡빡한지 자신감을 얻고 싶을 때 실행하라.
테스트 스위트가 이미 불안정하거나, 컴파일 시간이 모두가 불만을 터뜨리는 병목일 때는 실행하지 마라. 먼저 근본적인 문제를 고쳐라.
파이프라인을 망가뜨리지 않고 CI에 추가하기
실용적인 설정은 매 PR마다의 게이트가 아닌, 예약된 작업이다.
매주 실행하는 GitHub Actions 워크플로우는 다음과 같다:
name: Mutation Testing
on:
schedule:
- cron: "0 3 * * 1"
workflow_dispatch:
jobs:
mutants:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install cargo-mutants
run: cargo install cargo-mutants
- name: Run mutation testing
run: cargo mutants --in-place
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: mutants-report
path: mutants.out/
--in-place 플래그는 디스크 사용량을 합리적으로 유지한다. rust-cache는 초기 빌드 시간을 줄인다. 예약된 트리거는 개발자를 차단하지 않는다. 보고서를 아티팩트로 업로드하면 CI 로그를 스크롤하지 않고도 살아남은 mutant를 검토할 수 있다.
한 모듈부터 시작하라
전체 코드베이스를 변형할 필요는 없다. 비즈니스에 핵심적인 로직과 버그 이력이 있는 모듈 하나를 고르라. cargo mutants --file src/pricing.rs를 실행하라. 보고서를 읽어라. 가장 약한 테스트를 고쳐라.
첫 실행은 항상 최악이다. 코드를 실행하지만 아무것도 assertion하지 않는 테스트를 찾게 될 것이다. 분기 결과를 검사하지 않는 테스트에 의해 커버되는 분기를 찾게 될 것이다. 그런 테스트가 어떻게 적절하다고 느껴졌는지 의아해할 것이다.
그것이 핵심이다. 뮤테이션 테스트는 코드의 버그를 찾지 않는다. 테스트의 버그를 찾는다. Rust에서 컴파일러가 이미 명백한 실수를 잡아내는 환경에서, 이것이 바로 필요한 피드백 루프다.
자주 묻는 질문
뮤테이션 테스트란 무엇인가?
뮤테이션 테스트는 소스 코드에 작고 의도적인 버그를 주입하여 테스트 스위트를 평가한다. 테스트가 실패하면 mutant는 “killed”된다. 테스트가 통과하면 mutant는 “survived”하고, 이는 결함이 있는 것이다.
뮤테이션 테스트는 코드 커버리지와 어떻게 다른가?
커버리지는 어떤 라인이 실행되었는지 측정한다. 뮤테이션 테스트는 그 라인들에서 잘못된 출력이 나왔을 때 테스트가 이를 감지할지 측정한다. 테스트가 100% 커버리지를 가지고도 mutant를 하나도 잡아내지 못할 수 있다.
모든 Rust 프로젝트에서 뮤테이션 테스트가 느린가?
비용은 컴파일 시간과 테스트 개수에 비례한다. 작은 라이브러리는 몇 분이면 끝난다. 큰 워크스페이스 프로젝트는 훨씬 오래 걸린다. 특정 모듈로 범위를 좁히려면 --file과 --exclude를 사용하라.
거짓 양성 mutant를 무시할 수 있는가?
예. cargo-mutants는 파일, 함수, 특정 mutation 타입을 제외할 수 있는 mutants.toml 설정 파일을 지원한다. 실제 테스트 결함을 가리지 않도록 아껴서 사용하라.