Mutation Testing으로 테스트 품질을 측정하는 방법 (mutmut 실습 기록)
요약
본 기사는 테스트 커버리지의 한계를 지적하며, Mutation Testing을 통해 실제 테스트 품질을 측정하는 방법을 다룹니다. Python 도구 mutmut을 사용하여 멀티 에이전트 프레임워크 모듈에 543개의 변이를 적용하고 분석한 실습 기록입니다.
핵심 포인트
- Mutation Testing은 코드 실행 여부가 아닌, 어설션의 정확성을 검증합니다.
- 변이가 많이 생존(Survived)할수록 테스트 품질이 낮다는 의미입니다.
- 테스트 부족 지점을 파악하여 30개의 추가 테스트 케이스를 작성했습니다.
- 등가 뮤턴트 분석을 통해 불필요한 테스트와 실제 개선 영역을 구분했습니다.
테스트 커버리지 100%는 안심할 수 있게 하지만, '테스트가 올바른 것을 테스트한다'는 수단이 없습니다. 커버리지는 '테스트가 코드를 실행했는지'만 보여줄 뿐, '테스트가 정확하게 어설션(assertion)하고 있는지'는 알려주지 않습니다.
Mutation Testing은 이 문제에 접근하는 방법입니다. 코드에 의도적인 버그(변이, mutation)를 심고, 테스트가 그것을 탐지할 수 있는지 확인합니다.
본 기사에서는 Python의 Mutation Testing 도구인 mutmut을 실제 프로젝트에 도입하여 543개의 변이를 분석한 기록을 소개합니다.
Mutation Testing의 원리는 간단합니다.
1. 소스 코드의 일부를 자동으로 변경합니다 (변이 = mutant 생성)
예: if x > 0 → if x >= 0
예: return True → return False
...
Survived mutant가 많을수록 테스트 품질이 낮다는 것을 의미합니다.
저는 멀티 에이전트 프레임워크(NexusCore)의 세 가지 핵심 모듈을 대상으로 했습니다.
errors.py
— 오류 분류 및 변환 -
retry_policy.py
— 재시도 판단 로직 -
stacktrace_mapper.py
— 스택 트레이스 분석
pyproject.toml에 설정을 추가합니다.
[tool.mutmut]
paths_to_mutate = [
"src/nexuscore/core/errors.py",
...
실행 명령어:
mutmut run
src/
레이아웃과 pip install -e .의 조합으로 인해, mutmut의 트램폴린(변이 코드 전환 메커니즘)이 올바르게 작동하지 않았습니다. 구체적으로는, orig.__module__에 src. 접두사가 붙어 이름 해결에 실패했습니다. 이 문제를 2곳의 패치로 대응했습니다.
# trampoline_templates.py L44 — 접두사 제거
prefix = orig.__module__.removeprefix('src.') + '.' + orig.__name__ + '__mutmut_'
# __main__.py L139 — record_trampoline_hit에서 접두사 제거
...
이 문제는 mutmut v3.5.0 시점의 것이며, 향후 버전에서 수정될 가능성이 있습니다.
| 지표 | 값 |
|---|---|
| 총 뮤턴트 수 | 543 |
| ... | |
| 56.5%는 언뜻 낮아 보이지만, Mutation Testing에서는 60~70%도 양호하다고 간주됩니다. 이유는 후술합니다. |
첫 번째 결과를 바탕으로 30개의 테스트를 추가했습니다.
| 대상 모듈 | 추가 테스트 수 | 내용 |
|---|---|
| errors.py | 12건 | 키워드 매칭 경계 테스트, 우선순위 순서 테스트 |
| retry_policy.py | 18건 | unknown error type, max_attempts 초과, env helper 경계 테스트 |
최종 결과:
| 지표 | 값 |
|---|---|
| 총 뮤턴트 수 | 543 |
| ... | |
| 키워드 매칭의 경계 테스트 (errors.py): |
def test_classify_ratelimit_variant(self):
"'rate limit' → RateLimitError로 분류되는가"
error = ConnectionError(
{
"title": "낮은 Killed율에 대한 오해: 등가 뮤턴트 분석을 통한 실제 테스트 품질 진단",
"content": "Killed율이 59.9%라는 점만으로 '테스트 품질이 낮다'고 결론짓는 것은 성급합니다. 생존(Survived)된 218건의 모든 케이스를 분류했습니다.\n\n| 카테고리 | 건수 | 비율 |\n|---|---|---|\n| A: 등가 뮤턴트 (테스트 불필요) | 198 | 90.8% |\n| B: 표면적 비등가 (실질적으로 등가) | 20 | 9.2% |\n\n**생존한 218건 모두가 등가 뮤턴트였습니다.**\n\n| 서브 카테고리 | 건수 | 변이 내용 |\n|---|---|---|\n| 로그 메시지 문자열 대소문자 변경 | 27 | `"error"` → `"Error"` 등 |\n| 로그 extra dict의 키 변경 | 15 | `"error_type"` → `"ERROR_TYPE"` 등 |\n| 로그 extra dict의 값 변경 | 13 | `"retry"` → `None` 등 |\n| 에러 문구/문자열 상수 대소문자 변경 | 138 | `"None"` → `"none"` 등 |\n| 구문 오류가 되는 변경 | 5 | `config.get(\"key\", )` 등 |\n\n대부분(138건)은 문자열 리터럴의 대소문자 변경입니다. 예를 들어, 에러 메시지의 `\"Rate limit exceeded\"`를 `\"RATE LIMIT EXCEEDED\"`로 바꿔도 동작에는 변화가 없습니다.\n\n| 서브 카테고리 | 건수 | 변이 내용 | 실질적으로 등가인 이유 |\n|---|---|---|\n| 로직 분기 반전 | 5 | `exc is not None` → `exc is None` <br> | `except` 블록 내에서 `exc`는 비(非)None으로 확정되므로 |\n| 키워드 리스트 대소문자 변경 | 4 | `"json"` → `"JSON"` <br> | 실제 에러 타입명은 소문자이므로 매치되지 않음 |\n| 타입 변경 | 11 | 문자열/숫자 → `None` <br> | 검지 테스트가 문자열 비교에 치우치기 쉬움 |\n\n```\n실질적인 테스트 품질 = 325 / 325 = 100%\n(survived 218건은 모두 등가 뮤턴트 = 테스트로 죽여서는 안 되는 케이스)\n```\n\n등가 뮤턴트는 '코드를 변경해도 출력이나 동작이 변하지 않는 변이'입니다. 이는 테스트로 검출할 수 없을 뿐만 아니라, **검출하려고 시도해서도 안 됩니다**.\n\n구체적인 예시:\n\n```\n# 원본 코드\nexcept Exception as exc:\nif exc is not None: # ← 여기를 exc is None으로 변이\n...\n```\n\n`except` 블록 내부에서는 `exc`가 `None`이 될 수 없기 때문에, `exc is None`으로 변경해도 동작은 동일합니다. 이를 테스트로 '죽이는' 것은 불가능할 뿐만 아니라 필요하지 않습니다.\n\n| 프로젝트 종류 | 전형적인 등가 뮤턴트율 |\n|---|---|\n| 일반적인 제품 코드 | 30~50% |\n| ... | \n이번 대상 모듈(에러 분류 및 로그 출력 특화)의 경우, 90.8%의 등가율은 적절한 범위입니다.\n\n로그 메시지 문자열을 테스트로 검증한다면, 로그 관련 뮤턴트를 Killed 상태로 만들 수 있습니다. 하지만 이는 권장되지 않습니다.\n\n이유:\n\n- 로그 포맷의 사소한 변경으로 테스트가 깨질 수 있음\n- 유지보수 비용이 실익보다 큼\n- 테스트 본래의 목적(로직의 정확성 검증)에서 벗어남\n\n등가 뮤턴트가 집중되는 라인에 `# pragma: no mutate`를 추가하여 mutmut의 대상에서 제외합니다.\n\n```\nlogger.error(\"Rate limit exceeded\", extra={\"error_type\": \"rate_limit\"}) # pragma: no mutate\n```\n\n다만, 이번 분석에서는 218건 모두가 등가였기 때문에, 제외 설정은 진행하지 않고 '59.9%의 스코어는 실질적으로 100%이다'라는 판단 결과를 기록하는 데 그쳤습니다.\n\n59.9%만을 보고 '테스트가 불충분하다'고 단정하는 것은 위험합니다. Survived mutant 내용을 분석함으로써 실질적인 품질을 파악할 수 있습니다.\n\n에러 핸들링이나 로그 출력에 특화된 모듈은 필연적으로 등가 뮤턴트가 많습니다. 문자열 조작이 주를 이루는 처리에서는 대소문자 변이가 다량으로 생성되기 때문입니다.\n\nKilled율을 높이기 위해 로그 메시지 내용을 검증하는 테스트를 작성해서는 안 됩니다. 로직의 경계 조건이나 분기 망라(branch coverage)에 초점을 맞춘 테스트야말로 가치를 지닙니다."
}
mutmut v3.5.0과 src/レイアウト의 조합에서 트램폴린 문제(trampoline problem)가 발생했습니다. 도입하기 전에 프로젝트 구성과의 호환성을 확인하는 것이 중요합니다.
설치
pip install mutmut
설정 (pyproject.toml)
...
-
**실행 시간이 김**: 543개의 뮤턴트(mutant) 실행에 수십 분이 걸렸습니다. 대규모 프로젝트에서는 대상 범위를 좁힐 필요가 있습니다.
**동등 뮤턴트 판단의 주관성**: '정말로 동등한지'를 판단하려면 코드에 대한 깊은 이해가 필요합니다.
**도구의 성숙도**: mutmut은 활발하게 개발되고 있지만, 엣지 케이스(edge case)에서 문제에 직면할 수 있습니다.
Mutation Testing은 '테스트의 테스트'로서 강력한 방법론입니다. 커버리지만으로는 알 수 없는 테스트 품질의 약점을 정량적으로 특정할 수 있습니다.
다만, 점수라는 숫자에 현혹되지 않는 것이 중요합니다. Survived mutant 분석을 통해 '어떤 변이가 정말 문제인지'를 파악함으로써 실질적인 테스트 품질을 올바르게 평가할 수 있습니다.
- LLM의 환각(hallucination)을 구조적으로 방지하는 프롬프트 설계 (코드 리뷰에서의 실전)
- Claude Code CLI를 GLM (Z.AI)으로 대체한 이야기 (비용 대폭 절감 실측)
- 월간 27억 토큰을 처리한 LLM 라우팅의 실제 운영 보고서
본 기사는 개인 Zenn에서도 공개했습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기