# 검증 스크립트가 성공을 거짓말할 때: Negative Control과 Mutation으로 자기검증하기

> 검증 스크립트가 실제 결함을 탐지하는지 Negative Control과 입력 Mutation으로 확인하고, baseline과 실패 계약을 CI에 고정하는 방법을 정리합니다.

- Canonical: https://blog.jaemyeong.com/posts/verification-script-negative-control/
- Published: 2026.08.06
- Category: 기술
- Tags: #Testing, #CI-CD, #Automation, #Troubleshooting

릴리스 직전에 실행한 검증 스크립트가 모두 성공했다. 그런데 산출물을 직접 열어 보니 필수 파일이 빠져 있거나 서로 달라야 할 콘텐츠가 같았다. 스크립트는 실행됐지만 정작 보호해야 할 규칙은 검사하지 않은 상태였다.

크로스플랫폼 앱의 리소스와 메타데이터를 자동 검사할 때 이런 문제가 생긴다. 파일 탐색이 빗나가 입력이 비어도, 예외가 없으면 exit code 0을 반환할 수 있다.

exit code는 프로세스의 종료 상태다. 검증기가 필요한 결함을 감지할 능력이 있다는 증명은 아니다. 결함을 넣었을 때 예상한 이유로 실패하는지까지 확인해야 검증 결과를 신뢰할 수 있다.

이 글은 검증기 자체를 검증하는 방법만 다룬다. 플랫폼별 빌드나 테스트 매트릭스는 다루지 않는다. 깨끗한 baseline을 만든 뒤 의도적 결함 입력을 하나씩 넣고, 새로 발생한 실패의 종류를 확인하는 데 집중한다.

## 검증 스크립트의 성공은 검증 능력을 증명하지 않는다

검증 스크립트가 exit code 0으로 끝났다는 사실은 정의된 성공 경로에 도달했다는 뜻뿐이다. 그 경로가 릴리스 계약을 충분히 검사했다는 뜻은 아니다.

다음과 같은 스크립트는 잘못된 입력도 조용히 통과시킬 수 있다.

- glob 패턴이 실제 파일과 맞지 않아 검사 대상이 0개다.
- 필수 도구나 환경 변수가 없을 때 검사를 건너뛰고 성공한다.
- 생성기와 검증기가 같은 잘못된 기본값을 사용한다.

빈 입력과 skip-success도 구분해야 한다. 필수 산출물을 0개 찾았다면 입력 계약 위반이다. 선택 검사는 skip할 수 있지만, CI 필수 환경에서 도구나 입력이 없으면 설정 오류로 실패해야 한다.

`pytest`도 전체 통과와 테스트 0개 수집을 다른 exit code로 구분한다. 자체 검증기도 `pass`, `validation-failure`, `invalid-setup`, `skip`을 한 종류로 뭉개지 않아야 한다.

## 무엇을 실패시킬지 계약으로 적는다

검증기를 테스트하기 전에 “어떻게 실패해야 하는가”를 정한다. 0이 아닌 exit code만 기대하면 문법 오류나 파일 권한 문제로 중단돼도 조건을 만족한다.

실패 계약에는 최소한 다음 항목이 필요하다.

- 어떤 규칙을 검사했는지 식별하는 안정적인 `category` 또는 `rule_id`
- 실제로 검사한 입력 개수와 필수 검사 개수
- 검증 실패와 실행 환경 실패를 구분하는 exit code
- 0개 검사와 허용된 skip을 구분하는 상태

필수 이미지의 크기가 틀렸다면 `wrong_size`가 보고돼야 한다. 파일을 읽지 못해 `internal_error`로 끝났다면 결함을 제대로 잡은 것이 아니다.

CI는 바뀌기 쉬운 메시지 전체가 아니라 안정적인 실패 분류를 비교한다. 메시지는 디버깅 정보로 남긴다.

## Negative Control로 실패 경로를 고정한다

이 글에서 `Negative Control`은 **검증기가 반드시 거부해야 하는 의도적 결함 입력**이라는 작업 정의로만 사용한다. 실험·통계 용어를 확장해 적용하려는 것이 아니다.

가장 작은 Negative Control은 정상 입력의 복사본 하나를 바꾸는 방식이다. 허용되지 않은 `dark` 같은 값을 넣고 `invalid_value`가 발생하는지 보면 입력 읽기부터 실패 전달까지 확인할 수 있다.

운영 산출물을 훼손하지 않는다. 검토한 fixture를 임시 디렉터리에 복사해 변경하고 실행 후 버린다. 정상 입력이 실패하거나 결함 입력이 성공하면 결과를 신뢰할 수 없다.

## Mutation은 한 번에 하나만 적용한다

여기서 `Mutation`은 정상 fixture에 작은 결함을 의도적으로 넣는 행위다. 한 번의 실행에는 하나의 규칙만 바꾼다.

- 허용 목록에 없는 `dark` 같은 잘못된 값으로 교체한다.
- 필수 파일 하나를 삭제한다.
- 서로 달라야 할 locale 콘텐츠 두 개를 중복시킨다.
- 이미지 메타데이터를 잘못된 크기로 바꾼다.

여러 결함을 함께 넣으면 어떤 규칙이 작동했는지 알기 어렵다. 첫 오류에서 중단되면 뒤의 규칙은 실행되지 않는다. mutation 하나, 예상 실패 분류 하나를 기본 단위로 둔다.

Stryker와 PIT는 코드를 바꾸고 테스트가 변화를 감지하는지 확인한다. 다만 원본과 동작이 같은 equivalent mutant는 테스트로도 구분할 수 없다. 수동 입력 mutation도 실제 계약을 바꾸는 결함만 선택해야 한다.

## 깨끗한 baseline 뒤에 새 실패만 본다

fixture에 기존 실패가 있으면 검증기가 mutation을 놓쳐도 비정상 종료한다. exit code만 본 테스트는 이를 성공적인 탐지로 오해한다.

그래서 순서는 고정한다.

1. baseline의 exit code가 0이고 실패도 0개인지 확인한다.
2. fixture 복사본에 mutation 하나를 적용한다.
3. 다시 검증해 baseline에 없던 `introduced failure`를 계산한다.
4. 새 실패에 예상한 category가 있는지 확인한다.

다음 Python 코드는 이 흐름의 최소 형태다. 검증기는 JSON으로 실패 category와 실제 검사 수를 반환하고, `0`은 통과, `1`은 계약 위반, 그 밖의 값은 설정·내부 오류로 정의했다고 가정한다. 예제 fixture의 계약은 입력 4개이며, 매 case마다 새 임시 디렉터리에 복사한다.

```python
import json
import shutil
import subprocess
import tempfile
from pathlib import Path

FIXTURE = Path("validator-fixture")
PASS = 0
VALIDATION_FAILED = 1
REQUIRED_CHECKS = 4


def validate(root: Path) -> tuple[int, set[str], int]:
    result = subprocess.run(
        ["python", "validate.py", str(root), "--json"],
        check=False,
        capture_output=True,
        text=True,
    )
    report = json.loads(result.stdout)
    failures = {item["category"] for item in report["failures"]}
    return result.returncode, failures, report["checked"]


baseline_code, baseline_failures, baseline_checked = validate(FIXTURE)
if baseline_code != PASS or baseline_failures or baseline_checked != REQUIRED_CHECKS:
    raise SystemExit(
        f"invalid baseline: checked={baseline_checked}, "
        f"failures={sorted(baseline_failures)}"
    )

cases = [
    ("invalid-value", "invalid_value"),
    ("missing-file", "missing_file"),
    ("duplicate-content", "duplicate_content"),
    ("wrong-size", "wrong_size"),
]

for mutation, expected_category in cases:
    with tempfile.TemporaryDirectory() as temp:
        root = Path(temp) / "fixture"
        shutil.copytree(FIXTURE, root)
        subprocess.run(["python", "mutate.py", mutation, str(root)], check=True)

        code, failures, checked = validate(root)
        introduced_failures = failures - baseline_failures

        if code != VALIDATION_FAILED or expected_category not in introduced_failures:
            raise SystemExit(
                f"survived mutation: {mutation}, checked={checked}, "
                f"failures={sorted(failures)}"
            )
```

이 코드는 baseline 통과를 고정한 뒤 mutation이 예상한 실패를 새로 만드는지 확인한다. 기존 오류에 기대어 우연히 통과하는 것을 막는다.

CI 판정에는 `assert`를 사용하지 않았다. Python의 `-O` 옵션은 `assert` 문을 제거하므로, 검증 실패는 실행 옵션과 무관하게 남는 명시적 분기로 처리해야 한다.

이 예시는 category만 비교한다. 실패 대상도 구분해야 한다면 `rule_id`와 정규화된 상대 경로를 함께 비교한다. 전체 메시지나 출력 순서까지 고정할 필요는 없다.

## 생성기와 검증기가 같은 정답을 공유하면 함께 틀린다

생성기와 검증기가 같은 판단 함수를 쓰면 함께 틀릴 수 있다. 결과는 일관돼도 계약에는 맞지 않는다. 생성된 최신 산출물만 fixture로 써도 같은 문제가 생긴다.

중립적인 schema는 공유할 수 있다. 그러나 허용 값, 크기, 콘텐츠 고유성 판단은 생성 결과를 정답으로 삼지 않는다. 검증기는 명시된 계약과 검토된 fixture를 기준으로 정상·결함 입력을 분류한다.

## CI는 실패해야 성공인 검사를 실행한다

핵심 규칙마다 대표 Negative Control 하나를 두고, 검증기나 입력 탐색 로직이 바뀔 때 실행하면 된다.

CI 단계는 다음처럼 단순하게 유지한다.

1. 필수 도구와 fixture를 확인한다.
2. baseline이 exit code 0, 실패 0개, 필수 검사 수 이상인지 확인한다.
3. mutation을 임시 복사본에 하나씩 적용한다.
4. 각 실행이 예상 category로 실패하는지 확인한다.

필수 환경에서 입력이나 도구가 없으면 실패해야 한다. 선택 검사만 명시적인 skip으로 허용하고 사유를 남긴다. 테스트 0개, 검사 대상 0개, mutation 0개도 필수 CI에서는 실패 조건이다.

Mutation이 살아남으면 입력 탐색, 오류 반환, 기대 category를 확인한다. baseline과 예상·실제 category는 CI artifact에 남긴다.

## 체크리스트

- 정상 fixture의 baseline 실패가 0개인가.
- 필수 입력을 0개 찾으면 검증 실패로 처리하는가.
- 허용된 skip과 환경 누락을 다른 상태로 표현하는가.
- Negative Control이 실제 계약을 위반하는 입력인가.
- 한 실행에서 mutation을 하나만 적용하는가.
- 비정상 exit code뿐 아니라 예상 failure category를 확인하는가.
- baseline에 없던 introduced failure를 비교하는가.
- 생성기 구현과 검증 판단이 같은 함수에 의존하지 않는가.
- 필수 CI에서 검사 0개나 mutation 0개를 성공으로 바꾸지 않는가.

## 정리

검증기의 초록색 결과는 출발점일 뿐이다. 신뢰하려면 정상 입력에서 실패가 0개이고, 의도적 결함 입력에서는 예상한 규칙이 새로 실패해야 한다.

깨끗한 fixture와 몇 개의 Negative Control이면 시작할 수 있다. baseline, 단일 mutation, introduced failure를 차례로 확인한다. 검증 스크립트도 실패할 능력까지 테스트해야 한다.

## 출처

- [Stryker Mutator - What is mutation testing?](https://stryker-mutator.io/docs/)
- [Stryker Mutator - Mutant states and metrics](https://stryker-mutator.io/docs/mutation-testing-elements/mutant-states-and-metrics/)
- [PIT Mutation Testing - Basic Concepts](https://pitest.org/quickstart/basic_concepts/)
- [pytest documentation - Exit codes](https://docs.pytest.org/en/9.0.x/reference/exit-codes.html)
- [Python documentation - `-O` optimization option](https://docs.python.org/3/using/cmdline.html#cmdoption-O)
- [Google Testing Blog - Refactoring Tests in the Red](https://testing.googleblog.com/2007/04/tott-refactoring-tests-in-red.html)
