검증 스크립트를 일부러 깨진 입력으로 검증하기

출시 전에 돌리는 자동 점검이 전부 통과했는데도, 결과물에 필수 파일이 없거나 언어별로 달라야 할 내용이 똑같이 들어 있을 때가 있어요. 크로스 플랫폼 앱의 리소스와 메타데이터를 검사하는 스크립트에서 생기는 일이에요. 점검은 끝까지 돌았지만 지켜야 할 규칙은 한 번도 확인하지 않은 상태예요.

검사 스크립트가 결함을 실제로 잡는지는 결함이 든 입력을 넣어 봐야 알 수 있어요. 정상 샘플의 사본에 결함을 하나 넣고, 검사가 기대한 분류로 실패하는지 보는 방법을 정리해요. 플랫폼별 빌드와 테스트 조합은 다루지 않아요. 예제는 작은 검증기를 만들어 Python 3.14.5로 실행해 봤고, 그 결과를 예제 아래에 적었어요.

종료 상태 0이 말해 주지 않는 것

검사가 규칙을 건너뛰고도 성공으로 끝나는 경로는 여럿이에요.

  • glob이 맞지 않아 검사 대상이 0개가 돼요.
  • 도구나 환경 변수가 없을 때 성공으로 처리해요.
  • 생성하는 쪽과 검사하는 쪽이 같은 잘못된 기본값을 함께 써요.

어느 경우에도 종료 상태는 0일 수 있으므로, 0이라는 값만으로는 규칙을 검사했는지 판단할 수 없어요. pytest의 종료 코드도 이 둘을 구별해요. 수집한 테스트가 모두 통과하면 0이고, 수집한 테스트가 없으면 5예요.

직접 만드는 검사 스크립트에도 같은 구별을 두기를 제안해요. 필수 결과물을 찾지 못한 것은 입력 계약의 문제이고, 필수 CI에서 도구나 입력이 없는 것은 설정의 문제예요. 선택 점검을 건너뛴 것은 사유를 기록한 별도의 상태로 둬요. 상태의 후보는 pass, validation-failure, invalid-setup, skip 넷이에요. 이 넷은 pytest 문서가 정한 것이 아니고 제 설계예요.

실패를 판정하려면 종료 상태 말고도 정보가 더 있어야 해요.

  • category 또는 rule_id
  • 처리한 입력의 수와 필수 점검의 수
  • 환경 오류와 계약 위반을 나누는 종료 상태
  • 빈 대상과 허용된 생략의 구분

«비정상 종료했다»만 요구하면 문법 오류나 권한 문제까지 결함을 잡은 것으로 잘못 셀 수 있어요. 이미지 크기를 어긴 샘플이라면 기대하는 분류는 wrong_size예요. 파일을 읽지 못해서 나온 internal_error는 그 규칙이 동작했다는 증거가 아니에요. CI가 비교하는 기준은 안정된 분류값으로 하고, 설명 메시지는 원인을 조사할 때 써요.

결함을 하나만 넣은 샘플

이 글에서 negative control은 거부돼야 하는 조건을 갖춘 결함 샘플을 가리켜요. 통계나 실험 분야의 정의를 그대로 옮긴 말은 아니에요. 정상 자료의 사본에 허용되지 않는 값 dark를 넣고 invalid_value가 나오기를 요구하면, 입력을 읽는 곳부터 오류를 전달하는 곳까지 한 번에 점검할 수 있어요.

샘플은 검토를 마친 fixture를 임시 위치에 복제해서 바꾸고, 끝나면 버려요. 실제 배포 파일은 건드리지 않아요. 정상 자료가 거부되거나 결함 자료가 통과하면 그 검사는 믿을 조건을 채우지 못한 거예요.

정상 샘플에 계약 위반을 넣는 조작을 이 글에서는 mutation이라고 불러요. 한 번 실행할 때 규칙을 하나만 바꿔요. 허용되지 않는 값 dark 넣기, 필수 파일 지우기, 두 locale의 내용을 같게 만들기, 이미지의 크기 정보 바꾸기가 그 예예요. 결함을 여러 개 한꺼번에 넣으면 어느 것이 잡혔는지 가리기 어렵고, 첫 실패에서 멈추는 도구라면 뒤의 규칙은 확인하지 못해요. 그래서 조작 하나에 기대 분류 하나를 짝지어요.

Stryker나 PIT 같은 도구도 mutation이라는 말을 써요. 이 도구들은 프로그램 코드를 바꿔 놓고 테스트가 반응하는지 봐요. Stryker의 상태 설명에서 테스트가 하나라도 실패하면 mutant는 killed이고, 모두 통과하면 survived예요. PIT의 개념 안내에는 바꿔도 동작이 달라지지 않는 equivalent mutation이 나와요. 그런 변경은 테스트로 가려낼 수 없어요. 입력을 손으로 바꿀 때에도 마찬가지여서, 계약을 실제로 어기는 사례를 골라야 해요. 여기까지가 문서에 있는 내용이고, 이 개념을 입력 파일의 검사에 옮겨 쓰는 것은 제 해석이에요. 테스트가 실패하는 것을 보고 테스트를 믿는다는 생각은 Google Testing Blog의 글에도 있어요.

baseline을 먼저 확인하는 예제

결함 샘플을 넣기 전에 정상 샘플이 통과하는지부터 확인해요. 종료 상태가 0이고 실패가 0건이어야 해요. 이 조건이 없으면 이미 있던 결함 때문에 난 비정상 종료를 새 결함을 잡은 것으로 셀 수 있어요.

예제는 검증 결과 JSON에 failures의 category와 checked가 들어 있다고 전제해요. 종료 상태 0은 통과, 1은 계약 위반, 나머지는 설정이나 내부의 문제예요. 입력 계약은 4개예요. 앞부분은 검증기를 실행해 결과를 읽는 함수와 정상 샘플의 확인이에요.

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"),
]
if not cases:
    raise SystemExit("no mutation cases")

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 checked == 0 or expected_category not in introduced_failures:
            raise SystemExit(
                f"survived mutation: {mutation}, checked={checked}, "
                f"failures={sorted(failures)}"
            )

조작 사례의 목록이 비어 있으면 반복문에 들어가기 전에 실패로 끝나요. 조작한 뒤의 실패 집합에서 baseline의 실패 집합을 뺀 것이 새로 생긴 실패예요. 종료 상태가 1이고, 검사 수가 0이 아니고, 새 실패에 기대한 분류가 들어 있어야 통과예요. 이 예제에서는 baseline의 실패가 비어 있어야 다음으로 넘어가므로, 집합의 차는 조작 뒤의 실패 집합과 같아요. 기대한 분류 말고 다른 분류가 함께 나와도 통과로 봐요.

조건이 맞지 않으면 SystemExit를 일으켜요. assert를 쓰지 않은 이유는 Python의 -O 옵션이 assert 문을 제거하기 때문이에요. CI의 판정은 명시적인 조건 분기로 해요.

validate.py와 mutate.py, fixture의 실물은 이 글에 없어요. 실행해 볼 때에는 규칙 넷짜리 작은 검증기를 따로 만들었어요. 정상 검증기에서는 네 조작이 모두 검출됐어요. 조작한 사본에서 검사를 0건 하고도 기대한 분류를 보고하도록 검증기를 바꿔 봤더니, checked == 0 조건이 없는 판은 통과로 끝났고 조건을 넣은 판은 survived mutation으로 끝났어요. 이 조건과 빈 목록의 검사는 원래의 예제에 없던 것을 보탠 것이에요. JSON을 해석하지 못할 때의 처리와 환경 오류를 따로 다루는 코드도 예제에 없어요.

판정을 생성 코드와 떼어 놓기

생성 코드와 검사 코드가 같은 판단 함수를 쓰거나, 최신 생성물만으로 fixture를 만들면 같은 오류가 양쪽에서 가려질 수 있어요. 중립적인 schema를 함께 쓰는 것은 괜찮아요. 허용 값, 크기, 고유성의 기준은 명시된 계약과 사람이 검토한 자료에서 가져와요.

CI에서는 핵심 규칙마다 결함 샘플을 하나씩 두고, 검증 도구나 탐색 코드가 바뀔 때 실행하기를 제안해요. 순서는 이래요.

  1. 도구와 fixture가 있는지 확인해요.
  2. 정상 샘플의 상태와 최소 검사 수를 확인해요.
  3. 사본마다 조작을 하나씩 적용해요.
  4. 기대한 분류가 검출됐는지 확인해요.

필수 CI에서는 도구나 입력이 없을 때, 테스트 수가 0일 때, 검사 수가 0일 때, 조작 사례가 0개일 때 실패로 처리해요. 선택 항목의 생략만 이유와 함께 허용해요. 결함이 검출되지 않았다면 파일 탐색 방식, 오류 전달, 기대 분류의 설정을 살펴봐요. 정상 기준의 결과와 기대한 분류, 관측한 분류를 CI artifact로 남겨 두면 조사하기 쉬워요.

검토를 마친 샘플 몇 개로 시작할 수 있어요. 다만 이 예제가 비교하는 것은 category의 집합뿐이어서, 같은 분류의 실패가 여러 곳에서 나도 구별하지 못해요. 실패한 위치까지 봐야 하면 rule_id와 정규화한 상대 경로의 조합을 써요. CI 절차는 검사 수가 최소 기준 이상인지를 보지만, 예제는 정상 샘플의 검사 수가 4와 같은지를 봐요. 조작한 사본의 검사 수는 0인지만 보고, 최소 기준과 대조하지는 않아요. 동작이 달라지지 않는 변경은 이 방법으로도 가려낼 수 없어요.