차등 테스트가 실제로 주는 것
형식적 증명 없이도 차등 테스트는 믿을 수 있다. 하지만 그 한계가 어디인지 정확히 알아야 한다.
약점은 공통 모드 오류(common-mode failure)다. 하나의 명세에 대한 모든 구현이 같은 잘못된 가정을 할 때, 모두 같은 결과를 낸다. 그러면 테스트 하네스는 이를 통과로 인식한다. N-version 프로그래밍은 나쁜 명세로부터 당신을 지켜주지 않는다.
차등 테스트는 동일한 명세의 여러 독립 구현을 같은 입력에 대해 실행한다. 출력이 다르면 적어도 하나는 버그다. 같으면 일단 괜찮다고 본다.
이것이 강력한 이유는 테스트 오라클이 필요 없기 때문이다. 오라클은 모든 입력에 대한 정답을 아는 진리의 원천이다. 복잡한 시스템에서 오라클을 만드는 것은 시스템 자체보다 더 어려운 경우가 많다. 세금 엔진, 물리 시뮬레이션, 프로토콜 디코더는 모든 출력이 무엇이어야 하는지 증명하기 전에도 일관성을 테스트할 수 있다.
하지만 그 일시성이 중요하다. 일치는 일관성만 증명할 뿐, 정확성을 증명하지는 않는다.
왜 일치가 정확성은 아닌가
이론에서는 모두 배우지만 실무에서는 잊어버리는 실패 모드가 공통 모드 결함이다. 오류가 명세 자체에서 시작하거나, 모든 구현 팀이 독립적으로 같은 가정을 할 때, 모든 버전은 같은 잘못된 답을 낸다.
명세가 명백하게 틀릴 필요는 없다. 사람의 뇌가 같은 방식으로 해석하는 모서리 케이스에서 모호하기만 하면 된다.
정점 목록을 받아 단순 다각형의 면적을 계산하는 함수의 명세를 상상해 보자. 명세는 신발끈 공식을 제공한다. 정점의 순서는 언급하지 않는다.
세 팀이 이를 구현한다. 모두 예시 다이어그램이 반시계방향으로 그려져 있으므로 반시계방향 순서를 가정한다. 시계방향 입력은 순수 공식에서는 음수 면적을 낸다. 세 팀 모두 면적은 양수여야 한다며 abs()로 결과를 감싼다. 모든 테스트 케이스에서 일치한다.
하지만 명세는 시계방향이 무효라고 한 적이 없다. 구현은 일관성이 있지만 누락으로 인해 틀렸다. 차등 테스트는 이 모두를 통과시킨다.
세 가지 구현, 하나의 모호한 명세
직접 실행해 볼 수 있는 구체적인 예를 보자. 명세는 이렇게 말한다: “지속 시간 문자열을 파싱하여 총 초 수를 반환하라. 지속 시간은 하나 이상의 구성 요소로 이루어진다. 각 구성 요소는 양의 정수 뒤에 단위 문자가 온다: h는 시간, m은 분, s는 초.”
세 팀이 이 명세를 받아 각자 파서를 작성한다.
import re
def parse_duration_a(s):
"""Team A: regex approach."""
if not isinstance(s, str):
raise TypeError("input must be a string")
m = re.fullmatch(r"(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?", s)
if not m or not any(m.groups()):
raise ValueError(f"invalid duration: {s}")
h, mn, sec = (int(x or 0) for x in m.groups())
return h * 3600 + mn * 60 + sec
def parse_duration_b(s):
"""Team B: left-to-right scanner."""
if not isinstance(s, str):
raise TypeError("input must be a string")
total = 0
i = 0
while i < len(s):
j = i
while j < len(s) and s[j].isdigit():
j += 1
if j == i:
raise ValueError(f"expected number at position {i}")
num = int(s[i:j])
if j >= len(s):
raise ValueError(f"missing unit after {num}")
unit = s[j]
if unit == 'h':
total += num * 3600
elif unit == 'm':
total += num * 60
elif unit == 's':
total += num
else:
raise ValueError(f"invalid unit: {unit}")
i = j + 1
return total
def parse_duration_c(s):
"""Team C: state machine with duplicate detection."""
if not isinstance(s, str):
raise TypeError("input must be a string")
total = 0
seen = set()
i = 0
while i < len(s):
j = i
while j < len(s) and s[j].isdigit():
j += 1
if j == i:
raise ValueError("expected number")
num = int(s[i:j])
if j >= len(s):
raise ValueError("missing unit")
unit = s[j]
if unit in seen:
raise ValueError(f"duplicate unit: {unit}")
seen.add(unit)
i = j + 1
if unit == 'h':
total += num * 3600
elif unit == 'm':
total += num * 60
elif unit == 's':
total += num
else:
raise ValueError(f"invalid unit: {unit}")
return total
이제 세 구현에 같은 입력을 주고 불일치를 표시하는 차등 테스트 하네스를 실행해 보자.
def differential_test(implementations, inputs):
for case in inputs:
results = []
errors = []
for impl in implementations:
try:
results.append(impl(case))
except Exception as e:
errors.append(type(e).__name__)
if errors:
if len(errors) == len(implementations) and len(set(errors)) == 1:
print(f"ALL ERROR on {case!r}: {errors[0]}")
else:
print(f"MIXED on {case!r}: results={results}, errors={errors}")
else:
if len(set(results)) == 1:
print(f"AGREE on {case!r}: {results[0]}")
else:
print(f"DISAGREE on {case!r}: {results}")
IMPLS = [parse_duration_a, parse_duration_b, parse_duration_c]
CASES = [
"1h30m", # normal
"90m", # single unit
"30m1h", # out of order
"1h2h", # duplicate unit
"0h", # zero is not positive
"1.5h", # decimal
"1H", # wrong case
]
differential_test(IMPLS, CASES)
실행 결과는 다음과 같다:
AGREE on '1h30m': 5400
AGREE on '90m': 5400
MIXED on '30m1h': results=[5400, 5400], errors=['ValueError']
MIXED on '1h2h': results=[10800], errors=['ValueError', 'ValueError']
AGREE on '0h': 0
ALL ERROR on '1.5h': ValueError
ALL ERROR on '1H': ValueError
하네스는 실제 문제를 잡아낸다. 30m1h에서는 A팀의 정규식이 순서가 뒤바뀐 입력을 거부하고 B팀과 C팀은 받아들인다. 1h2h에서는 B팀의 스캐너가 두 시간을 조용히 더하고 C팀의 중복 감지는 오류를 발생시킨다. 이것이 차등 테스트가 찾아야 할 버그다.
하지만 0h를 보자. 명세는 “양의 정수”라고 했다. 0은 양수가 아니다. 세 구현 모두 이를 받아들인다. 왜냐하면 어떤 팀도 당연해 보이지만 실제로는 강제되지 않은 요구사항에 대한 검증을 작성하지 않았기 때문이다. 일치하므로 테스트는 통과한다. 이것이 눈에 띄지 않는 공통 모드 오류다.
1H에서도 같은 일이 일어난다. 명세는 소문자 단위를 보여줬으므로 세 팀 모두 이를 거부한다. 하지만 명세가 대소문자를 구분하지 않는 매칭을 의도했다면 모든 구현이 틀렸고 함께 틀린 것이다.
차등 테스트를 덜 틀리게 만드는 법
형식적 증명 없이 공통 모드 오류를 완전히 없앨 수는 없다. 하지만 그 가능성을 줄일 수는 있다.
인력만 다양화하지 말고 구현 전략을 다양화하라. 모든 팀이 같은 교과서의 같은 알고리즘을 쓴다면 다양성을 만든 게 아니다. 지연만 만든 것이다. 한 팀은 상태 머신을, 다른 팀은 parser generator을, 또 다른 팀은 재귀를 쓰도록 강제하라. 다른 알고리즘은 다른 입력에서 실패한다.
다른 프로그래밍 언어를 사용하라. 공유 표준 라이브러리 버그는 전형적인 공통 모드 오류다. 모든 구현이 같은 JSON 파서나 같은 부동소수점 수학 라이브러리를 쓴다면 그 버그도 공유한다.
적대적 오라클을 추가하라. 누군가에게 명세가 모호한 입력을 찾는 일을 맡겨라. 그들의 임무는 구현들이 불일치하도록 만드는 것이다. 구현들을 갈라놓는 입력이 당신이 작성할 가장 가치 있는 테스트다.
공격적으로 퍼징(fuzzing)하라. 몇 개의 손으로 고른 예제에서의 일치는 약한 증거다. 백만 개의 무작위 생성 입력에서의 일치는 더 강한 증거다. 퍼징은 어떤 팀도 고려하지 않은 입력 공간의 구석을 발견한다.
명세 자체를 테스트하라. “양의 정수” 같은 요구사항에 대한 명시적인 부정 테스트를 작성하고 적어도 하나의 구현이 이를 거부하는지 확인하라. 세 구현 모두가 무효한 입력을 받아들인다면 코드가 아니라 명세를 조여야 한다.
차등 테스트가 충분할 때
차등 테스트는 형식적 검증의 대체재가 아니다. 필터다. 증명에 투자하기 전에 구현 버그를 값싸고 일찍 잡아낸다.
질문은 이것을 믿을 수 있는가가 아니다. 무엇을 위해 믿을 수 있는가가 질문이다. 불일치를 찾는 데는 믿을 수 있다. 나쁜 명세가 있는 상황에서 보편적 일치를 찾는 데는 믿을 수 없다.
안전 필수 소프트웨어를 만든다면 차등 테스트는 예비 단계다. 실행하고, 불일치를 고친 다음, 명세를 model checker나 증명 보조기에 제출하라. 웹 서비스를 만든다면 차등 테스트가 기능 브랜치에 필요한 모든 신뢰일 수도 있다. 증명은 위험에 비례한다.
오늘 N-version 테스트 스위트를 실행하고 있다면 테스트 케이스를 하나 더 추가하라. 명세가 명확하게 정의하지 않은 입력을 찾아라. 구현에 통과시켜라. 모두 일치한다면 좋은 테스트를 찾은 게 아니다. 명세의 구멍을 찾은 것이다.
당신을 죽이는 버그는 구현들이 불일치하는 게 아니다. 잘못된 이유로 일치하는 것이다.
자주 묻는 질문
차등 테스트란 무엇인가?
차등 테스트는 동일한 명세의 여러 독립 구현을 동일한 입력으로 실행하는 기법이다. 출력을 비교한다. 불일치는 모든 입력에 대한 사전 존재하는 진리 원천 없이도 버그를 드러낸다.
소프트웨어에서 공통 모드 오류란 무엇인가?
공통 모드 오류는 여러 독립 구성 요소가 같은 입력에 대해 같은 근본적인 이유로 실패할 때 발생한다. N-version 프로그래밍에서는 보통 명세가 모호하고 모든 팀이 그 모호성을 같은 방식으로 해결할 때 일어난다.
차등 테스트와 속성 기반 테스트의 차이는 무엇인가?
속성 기반 테스트는 출력이 “리스트를 정렬해도 길이는 변하지 않는다” 같은 일반 규칙을 만족하는지 확인한다. 차등 테스트는 여러 구현이 같은 출력을 내는지 확인한다. 두 기법은 서로 보완된다. 속성 기반 테스트는 논리 오류를 찾는다. 차등 테스트는 불일치를 찾는다.
차등 테스트를 위해 LLM으로 다양한 구현을 생성할 수 있는가?
할 수 있지만 주의해야 한다. 중복된 코퍼스로 학습된 모델은 상관관계 있는 실패 모드를 가진 코드를 생성하는 경향이 있다. 최근 연구에 따르면 AI 생성 구성 요소의 공동 오류율은 15%에서 30% 사이이다. LLM을 사용한다면 진정으로 다른 알고리즘과 언어를 요구하라. 의미적으로 동일한 재작성의 모음은 다양성이 아니다.