테스트가 모두 통과했다면, 일부러 틀리게 만든 집계 함수도 통과하는지 확인해 볼 수 있습니다. 정상 함수에는 통과하고 오류를 넣은 함수에는 실패해야, 그 테스트가 해당 오류를 구별한다고 말할 수 있습니다. 이 글에서는 AI 평가 결과를 세는 함수에 세 가지 오류를 하나씩 넣고, 어떤 입력과 검증문이 빠져 있었는지 찾습니다.
아래 데이터는 설명용 가상 사례입니다. Python 기본 문법과 함수, 테스트의 통과·실패를 안다면 별도 패키지나 API 없이 실습할 수 있습니다.

먼저 집계 함수의 약속을 고정합니다
평가 대상 작업마다 최종 상태 하나가 이미 정리되어 있다고 가정합니다. 상태는 pass, fail, unknown 중 하나입니다. 이 단계에서는 작업 ID의 중복이나 재시도 이력을 해결하지 않습니다. 그런 정리는 집계 전에 끝나 있어야 합니다. 함수는 (성공 수, 전체 대상 수)를 반환하며, unknown도 전체 대상 수에 남깁니다. unknown을 실패 판정으로 바꾸는 것은 아니고, 확인된 성공 비율을 계산할 때 분모에서 임의로 빼지 않는 규칙입니다.
예를 들어 A가 pass, B가 fail, C가 unknown이면 반환값은 (1, 3)입니다. 이 값으로 계산한 1/3은 전체 대상 중 확인된 성공의 비율입니다. 완료된 작업만의 성공률 1/2와 질문이 다릅니다. 빈 목록, 기본 list가 아닌 값(하위 클래스 포함), 허용하지 않은 상태는 ValueError로 거절합니다. 빈 집단의 비율을 0%로 채우지 않기 위해서입니다.
정상 코드가 통과하는 것만으로는 부족한 이유
테스트가 [pass, pass]를 넣고 (2, 2)를 기대한다고 합시다. 정상 함수는 통과합니다. 그런데 unknown을 분모에서 빼는 함수도 이 입력에서는 (2, 2)를 반환합니다. fail을 성공으로 세는 함수 역시 fail을 만나지 않아 통과합니다. 실행은 됐지만, 확인하고 싶은 차이가 입력에 없었던 것입니다.
이처럼 코드의 작은 부분을 의도적으로 바꾼 뒤 기존 테스트가 실패하는지 보는 방법을 변이 테스트(mutation testing)라고 부릅니다. 여기서는 자동 변이 도구를 설치하지 않고, 사람이 검토할 수 있는 짧은 함수 변형을 직접 작성합니다. 운영 코드의 수정안이 아니라 테스트를 점검하기 위한 비교 대상입니다.
| 변형 | 바꾼 규칙 | 혼합 입력의 반환값 |
|---|---|---|
| 정상 함수 | pass만 성공, 모든 작업을 분모에 포함 | (1, 3) |
| drop_unknown | unknown을 분모에서 제외 | (1, 2) |
| count_fail_as_pass | fail도 성공으로 계산 | (2, 3) |
| always_zero | 성공 수를 항상 0으로 반환 | (0, 3) |
| equivalent_count | count 대신 참인 비교의 개수를 합산 | (1, 3) |
마지막 변형은 일부러 틀리게 만든 것이 아닙니다. 허용된 입력에서 pass의 개수를 세는 두 표현이 같은 결과를 내는지 비교하기 위해 넣었습니다. 코드를 바꿨다는 사실만으로 오류가 생겼다고 가정하면, 이런 동등 변형까지 테스트의 결함으로 오해하게 됩니다.
복사해서 실행할 수 있는 최소 실습
아래 전체 코드를 mutation_audit.py로 저장한 뒤 python mutation_audit.py로 실행합니다. 정상 함수와 네 변형이 같은 테스트를 받습니다. weak는 성공 사례 한 가지, strong은 혼합 상태·전체 미확인·잘못된 입력까지 포함한 네 가지 테스트 메서드입니다. 잘못된 입력 다섯 개는 한 메서드 안의 하위 사례이므로 테스트 메서드 다섯 개로 세지 않습니다.
"""Synthetic, local-only test-suite demonstration. No API calls."""
import io
import unittest
def validate(statuses):
if type(statuses) is not list or not statuses:
raise ValueError("nonempty list required")
if any(type(s) is not str or s not in {"pass", "fail", "unknown"}
for s in statuses):
raise ValueError("invalid status")
def correct(statuses):
validate(statuses)
return statuses.count("pass"), len(statuses)
def drop_unknown(statuses):
validate(statuses)
return statuses.count("pass"), sum(s != "unknown" for s in statuses)
def count_fail_as_pass(statuses):
validate(statuses)
return sum(s != "unknown" for s in statuses), len(statuses)
def always_zero(statuses):
validate(statuses)
return 0, len(statuses)
def equivalent_count(statuses):
validate(statuses)
return sum(s == "pass" for s in statuses), len(statuses)
IMPLEMENTATIONS = {
"correct": correct,
"drop_unknown": drop_unknown,
"count_fail_as_pass": count_fail_as_pass,
"always_zero": always_zero,
"equivalent_count": equivalent_count,
}
def run_suite(fn, strong=False):
class ContractTests(unittest.TestCase):
def test_all_pass(self):
self.assertEqual(fn(["pass", "pass"]), (2, 2))
def test_mixed(self):
self.assertEqual(fn(["pass", "fail", "unknown"]), (1, 3))
def test_all_unknown(self):
self.assertEqual(fn(["unknown"]), (0, 1))
def test_invalid(self):
for bad in ([], None, "pass", ["PASS"], [1]):
with self.subTest(value=bad):
with self.assertRaises(ValueError):
fn(bad)
names = ["test_all_pass"]
if strong:
names += ["test_mixed", "test_all_unknown", "test_invalid"]
suite = unittest.TestSuite(ContractTests(name) for name in names)
result = unittest.TextTestRunner(stream=io.StringIO()).run(suite)
# This lesson counts assertion failures only. Unexpected errors stay separate.
state = ("error" if result.errors else
"detected" if result.failures else "passed")
return state, result.testsRun
if __name__ == "__main__":
for name, fn in IMPLEMENTATIONS.items():
weak = run_suite(fn)
strong = run_suite(fn, strong=True)
print(f"{name}: weak={weak[0]}({weak[1]}), "
f"strong={strong[0]}({strong[1]})")
실행 결과에서 읽을 수 있는 것
2026년 11월 6일 Python 3.12.14에서 위 코드를 실행해 다음 출력을 확인했습니다.
correct: weak=passed(1), strong=passed(4)
drop_unknown: weak=passed(1), strong=detected(4)
count_fail_as_pass: weak=passed(1), strong=detected(4)
always_zero: weak=detected(1), strong=detected(4)
equivalent_count: weak=passed(1), strong=passed(4)
weak는 항상 0을 반환하는 오류만 잡습니다. 성공 둘을 넣었을 때 0이 나오므로 기대값 (2, 2)와 어긋납니다. 반면 분모 누락과 실패의 성공 오분류는 그대로 통과합니다. strong에 [pass, fail, unknown] → (1, 3)을 추가하면 세 오류의 결과가 모두 기대값과 달라집니다. 전체 unknown 사례는 분모가 0으로 줄어드는 경계도 따로 확인합니다.
여기서 오류 변형 세 개만 분모로 삼으면 weak의 탐지 비율은 1/3, strong은 3/3입니다. 정상 함수와 동등 변형은 이 분모에 넣지 않았습니다. 이는 이 글에서 직접 선정한 세 오류에 대한 결과입니다. 자동 도구가 생성한 모든 변형에 대한 점수나 제품 전체의 품질 수치로 옮겨 적을 수 없습니다. 쉬운 오류만 골라도 이 비율은 높아집니다.
코드에서 detected는 기대값 검증이 실패했다는 뜻입니다. 예상하지 못한 예외는 error로 따로 표시합니다. 변형 코드가 일으킨 예외도 실제 버그를 드러낼 수 있지만, 이 실습에서는 원인을 확인한 뒤 판단하도록 분리했습니다. 예외가 났다고 무조건 “오류를 잘 잡았다”고 세면, 테스트 실행기 자체의 고장까지 좋은 결과로 오인할 수 있습니다. 이 예제에는 시간 제한이나 코드 커버리지 측정이 없으므로 timeout, 미실행 코드의 비율도 보고하지 않습니다.
살아남은 변형을 처리하는 순서
변형이 통과했다면 먼저 정상 코드와 다른 결과를 내는 유효한 입력을 찾아봅니다. drop_unknown에는 unknown이 있는 목록이, count_fail_as_pass에는 fail이 있는 목록이 필요합니다. 그 입력에서 기대해야 할 값을 집계 약속으로 직접 계산하고 테스트에 추가합니다. 변형 함수가 실제로 반환한 값을 그대로 기대값으로 복사해서는 안 됩니다.
차이를 만드는 입력을 찾지 못했다고 바로 동등하다고 표시하지는 않습니다. equivalent_count는 이 예제의 입력 검증 뒤에서는 모든 원소가 문자열이고, pass와 같은 원소마다 참인 비교 하나가 생깁니다. 따라서 그 합과 pass의 개수가 같다는 이유를 설명할 수 있습니다. 다른 입력 형식이나 사용자 정의 객체까지 허용한다면 그 설명의 범위도 다시 확인해야 합니다. 일반적인 큰 프로그램에서는 동등성 판단 자체가 어려울 수 있습니다.
그다음 정상 함수가 보강한 테스트에도 통과하는지 확인합니다. 정상 함수까지 실패하면 기대값이 틀렸거나, 요구사항과 구현이 어긋난 것일 수 있습니다. 그런 상태에서 변형 탐지 비율부터 해석하지 않습니다. 테스트를 한 개씩 늘릴 때마다 정상 함수와 변형 모두를 다시 실행하면 어느 검증문이 어떤 오류를 가르는지 남길 수 있습니다.
자기 프로젝트에 옮길 때 남길 기록
먼저 실패하면 손실이 큰 집계 규칙 하나를 고릅니다. 성공 수, 분모, 비용 합계, 미확인 처리 중 하나면 충분합니다. 오류를 여러 곳에 동시에 넣으면 어느 차이 때문에 테스트가 실패했는지 흐려집니다. 실행이 끝나지 않을 수 있는 변형이나 외부 쓰기를 하는 함수는 이처럼 같은 프로세스에서 바로 실행하지 말고, 별도의 격리와 시간 제한을 먼저 설계해야 합니다.
집계 규칙: unknown도 전체 대상 수에 포함한다.
변형: 분모에서 unknown을 제외한다.
차이를 드러내는 입력: [pass, fail, unknown]
계약에 따른 기대값: (1, 3)
정상 함수: 통과
변형 함수: 기대값 검증 실패
판단: 이 분모 오류는 현재 테스트가 탐지한다.
남은 범위: 작업 ID 누락·중복, 시간 기준, 모델 판정의 정확성은 별도 검사한다.
테스트가 오류를 잡는다는 사실과 집계 약속 자체가 올바르다는 판단도 분리해야 합니다. 세 개의 오류를 모두 잡아도, 잘못 정한 성공 기준을 충실하게 구현했을 수 있습니다. 검증할 규칙은 업무 목적과 원자료에 대조하고, 테스트는 그 규칙이 코드에 유지되는지 확인하는 데 사용합니다. 다음 검토자가 확인할 것은 높은 점수보다 “어떤 오류를 어떤 입력으로 구별했는가”입니다.
함께 읽기와 확인한 근거
작업별 결과와 재시도 행을 먼저 나누어야 한다면 AI 재시도 로그 검산기: CSV 성공률 계산과 Python 실습을 함께 볼 수 있습니다. 이 글의 상태 목록은 그처럼 평가 단위가 정리된 뒤의 입력을 전제로 합니다.
공식 문서 확인일: 2026년 11월 6일. Python unittest 문서에서 TestCase, assertEqual, assertRaises와 테스트 실행 방식을 확인했습니다. Stryker의 변형 상태·지표 문서는 탐지, 생존, 오류 상태와 점수 분모를 구분하는 참고 자료입니다. 이 실습은 Stryker를 실행한 결과가 아니며, 출력 상태와 탐지 비율은 위 코드에서 정의한 좁은 범위로 해석합니다.