Metamorphic testing은 GCC와 LLVM에서 147개의 확인된 버그를 찾았고, 자동차 OEM이 사용하는 상용 ADAS 시뮬레이터의 결함을 발견했으며, 보행자 사망 사고 8일 전 자율주행차 인지 시스템의 치명적인 결함을 잡아냈다. 이 기법은 학술적으로 들리지만, 버그는 전혀 그렇지 않다.
문제는 오라클 문제다. 많은 프로그램은 입력을 실행할 수는 있지만, 출력이 올바른지 독립적으로 검증할 수 없다. 10,000개 노드가 있는 도로망의 정확한 최단 경로는 무엇인가? 이 컴파일러 최적화는 의미를 보존하는가? 이 ML 모델의 분류가 실제로 맞는가? 당신은 모른다. 기존의 단위 테스트는 여기서 물러선다. expected가 무엇이어야 할지 전혀 모르는 상황에서 assertEquals(expected, actual)를 작성할 수 없기 때문이다.
Metamorphic testing은 출력 자체를 확인하지 않음으로써 이 문제를 우회한다. 출력 사이의 관계를 확인한다.
Metamorphic testing이란?
Metamorphic testing은 입력을 관련된 입력으로 변환하고, 둘 다 프로그램에 통과시킨 뒤, 두 출력이 알려진 수학적·논리적 관계를 따르는지 단언하는 기법이다. 이 관계를 metamorphic relation이라 부른다.
프로그램이 숫자 목록의 평균을 계산한다면, [4.2, 1.7, 9.3, 2.1]의 정확한 평균을 몰라도 테스트할 수 있다. 목록을 섞어도 결과가 같아야 하고, 모든 요소를 두 배로 하면 평균도 두 배가 되어야 한다는 것만 알면 된다. 이것들이 바로 metamorphic relation이다.
첫 번째 입력을 source test case라 부르고, 변환된 입력을 follow-up test case라 부른다. 오라클은 관계 자체다.
다음은 파이썬의 구체적인 예시다:
import random
def compute_average(numbers):
"""Returns the arithmetic mean of a list of numbers."""
if not numbers:
raise ValueError("empty list")
return sum(numbers) / len(numbers)
def test_average_permutation_invariant():
"""MR-1: Shuffling the input should not change the average."""
source = [4.2, 1.7, 9.3, 2.1, 5.6]
follow_up = source.copy()
random.shuffle(follow_up)
source_out = compute_average(source)
follow_up_out = compute_average(follow_up)
assert source_out == follow_up_out, (
f"Permutation MR failed: {source_out} != {follow_up_out}"
)
def test_average_scaling():
"""MR-2: Doubling every element should double the average."""
source = [3.0, 6.0, 9.0]
follow_up = [x * 2 for x in source]
source_out = compute_average(source)
follow_up_out = compute_average(follow_up)
assert follow_up_out == source_out * 2, (
f"Scaling MR failed: {follow_up_out} != {source_out * 2}"
)
def test_average_inclusion():
"""MR-3: Appending the average itself should not decrease the average."""
source = [10.0, 20.0, 30.0]
source_out = compute_average(source)
follow_up = source + [source_out]
follow_up_out = compute_average(follow_up)
assert follow_up_out == source_out, (
f"Inclusion MR failed: {follow_up_out} != {source_out}"
)
if __name__ == "__main__":
test_average_permutation_invariant()
test_average_scaling()
test_average_inclusion()
print("All metamorphic relations passed.")
이 관계들 중 하나라도 실패하면, 평균을 직접 손으로 계산하지 않고도 버그를 찾은 것이다. 이것이 핵심 아이디어다.
실제 운영 환경에서 발견된 버그
이 기법은 이론이 아니다. 다음은 metamorphic testing이 실제 운영 소프트웨어에서 버그를 찾은 기록된 사례들이다.
GCC와 LLVM의 147개 버그
연구자들은 metamorphic testing을 C 컴파일러 최적화 파이프라인에 적용해 GCC와 LLVM에서 147개의 확인된 버그를 찾았다. 이것들은 장난감 프로그램이 아니었다. 올바른 C 프로그램을 최적화 컴파일러에 통과시켰을 때 잘못된 머신 코드를 생성하는 실제 miscompilation 버그였다. 일부는 수년 동안 존재해왔다. metamorphic relation은 단순했다: 함수를 수동으로 인라인하면 최적화된 출력이 원래와 동일하게 동작해야 한다. 독립적인 문장을 순서를 바꿔도 결과가 달라지지 않아야 한다. 컴파일러 개발자들이 이 버그들을 확인하고 수정했다.
구글의 Vulkan 셰이더 컴파일러
구글 GraphicsFuzz 팀은 randomized metamorphic testing을 Khronos Vulkan Conformance Test Suite에 상용 도입했다. 무작위 프래그먼트 셰이더를 생성하고, 의미 보존 변환(식을 아이덴티티 함수로 감싸거나 데드 코드를 추가하는 등)을 적용한 뒤, 여러 컴파일러와 GPU에서 렌더링된 이미지를 비교했다. 두 개의 동등해야 하는 셰이더가 다른 픽셀을 생성하면 컴파일러 버그를 찾은 것이다. 이 팀은 gfauto라는 전체 파이프라인을 구축해 이 사례들을 축소하고 중복 제거하고 보고했다. Vulkan 셰이더를 변환·최적화·검증하는 툴 에코시스템에서 버그를 발견했으며, 여기에는 최종 사용자에게 배포된 프로덕션 드라이버도 포함된다.
자동차 OEM이 사용하는 ADAS 시뮬레이터
한 팀이 세 가지 인기 있는 ADAS 시뮬레이션 플랫폼인 Simulink, CarMaker, 51Sim-One Cloud를 테스트했으며, 특히 차선 유지 보조 시스템에 초점을 맞췄다. 일반 테스트 케이스는 세 플랫폼 모두에서 통과했다. 전혀 문제가 없었다. 그런 다음 팀은 기하학적 metamorphic relation을 적용했다: 도로 장면을 수평으로 미러링하고, 차량 위치를 회전시키고, 차선 표시에 아핀 변환을 적용했다. 출력은 예측 가능하게 변환되어야 한다. 그러지 않았다. 세 플랫폼 모두에서 버그가 드러났다. MathWorks와 IPG Automotive가 나중에 이 문제를 확인했다. 이들은 차량에 탑재되기 전 소프트웨어를 검증하는 데 사용되는 동일한 플랫폼이다.
동일한 클래스의 self-driving car 결함
가장 뼈아픈 사례 중 하나에서, 연구자들은 자율주행차의 물체 인식 시스템에 metamorphic testing을 적용해 인지 파이프라인의 버그를 발견했다. 이 시스템은 특정 변환된 입력에서 보행자를 올바르게 분류하지 못했다. 이를 보고했다. 그들이 찾은 버그는 치명적인 self-driving car 보행자 충돌과 관련된 동일한 유형의 결함이었다.
트레이드오프: 관계는 도메인별이다
Metamorphic testing은 강력하지만 공짜는 아니다. 어려운 부분은 좋은 metamorphic relation을 식별하는 것이다. 나쁜 관계는 거짓된 자신감을 준다. 너무 약한 관계는 버그를 잡지 못한다. 너무 강한 관계는 부동소수점 노이즈나 비결정성으로 인해 올바른 동작에서도 실패할 수 있다.
관계 설계에는 도메인 지식이 필요하다. 최단 경로 알고리즘의 경우 좋은 관계에는 다음이 포함된다: 무방향 그래프에서 A→B 경로 비용은 B→A와 같아야 한다; 모든 간선 가중치에 상수를 더하면 총 경로 비용에 그 상수 × 간선 수를 더한 만큼 증가해야 한다. 정렬 알고리즘의 경우: 정렬된 목록을 뒤집고 다시 정렬하면 원래 정렬된 목록의 역순이 나와야 한다; 출력의 모든 요소는 입력에 동일한 빈도로 나타나야 한다.
관계를 관련 없는 시스템 간에 재사용할 수 없다. 이것이 비용이다.
부동소수점 연산은 또 다른 함정이다. 많은 관계는 정확한 동일성을 가정하지만, IEEE 754에서 0.1 + 0.2 != 0.3이다. 허용 오차 기반 비교가 필요하고, 올바른 허용 오차를 선택하는 것 자체가 문제다. 너무 엄격하면 거짓 양성이 생기고, 너무 느슨하면 실제 버그를 놓친다.
Codebase에 metamorphic testing 추가하는 방법
프레임워크가 필요한 것이 아니다. 규율이 필요하다.
당신의 codebase에서 오라클이 없는 함수부터 시작하라. ML 추론, 최적화 알고리즘, 기하 계산, 통계 집계, 시뮬레이션 코드가 모두 후보다. 각각에 대해 다음을 물어보라: 입력을 특정하고 예측 가능한 방식으로 변경하면 출력에 대해 무엇이 반드시 참이어야 하는가?
테스트 함수당 하나의 metamorphic relation을 작성하라. 이름을 명확하게 지어라. CI에서 단위 테스트와 함께 실행하라. 관계가 실패하면 다른 테스트 실패와 정확히 동일하게 처리하라.
다음은 경로 탐색 함수를 테스트하는 약간 더 현실적인 예시다:
import math
def shortest_path_cost(graph, start, end):
"""Returns the cost of the shortest path. Assume implemented."""
pass
def test_shortest_path_undirected_symmetry():
"""MR: In an undirected graph, path cost A->B equals B->A."""
graph = {
'A': [('B', 3.0), ('C', 1.0)],
'B': [('A', 3.0), ('C', 1.0)],
'C': [('A', 1.0), ('B', 1.0)],
}
ab = shortest_path_cost(graph, 'A', 'B')
ba = shortest_path_cost(graph, 'B', 'A')
assert math.isclose(ab, ba, rel_tol=1e-9), f"Symmetry failed: {ab} != {ba}"
def test_shortest_path_subpath():
"""MR: The shortest path cost cannot exceed any specific path's cost."""
graph = {
'A': [('B', 2.0), ('C', 10.0)],
'B': [('C', 2.0)],
'C': [],
}
cost = shortest_path_cost(graph, 'A', 'C')
assert cost <= 10.0, f"Subpath MR failed: {cost} > 10.0"
assert math.isclose(cost, 4.0, rel_tol=1e-9), f"Expected 4.0, got {cost}"
당신은 알고리즘 자체를 테스트하는 것이 아니다. 당신의 구현을 테스트하는 것이다.
FAQ
Metamorphic testing이 단위 테스트를 대체하는가?
아니다. 보완한다. 예상 출력을 알 때는 단위 테스트를 사용하라. 예상 출력을 모를 때는 metamorphic test를 사용하라.
ML 모델에도 사용할 수 있는가?
예, 그리고 이것은 가장 활발한 연구 분야 중 하나다. “고양이 이미지를 회전해도 여전히 고양이로 분류되어야 한다”는 것과 같은 관계가 metamorphic relation이다. 연구자들은 이 접근법으로 모델 신뢰성 문제와 공정성 격차를 발견했다.
Metamorphic relation이 올바른지 어떻게 알 수 있는가?
증명하지는 않는다. 명세서나 도메인의 수학적 특성에서 논증한다. 관계 자체에 버그가 있으면 거짓 양성이 나온다. 명백한 특성부터 시작하고, 자신감이 생기면서 더 추가하라.
불안정한 테스트는 어떻게 되는가?
비결정적 시스템(확률적 알고리즘, 동시성 코드, 타임아웃이 있는 시스템)은 metamorphic testing을 더 어렵게 만든다. 여러 번 시도를 실행하거나, 정확한 동등성 대신 통계적 관계를 사용해야 할 수도 있다.
하나의 관계부터 시작하라
이것을 사용하는 데 박사 학위가 필요한 것은 아니다. 현재 출력을 검증하기가 너무 어려워서 테스트를 건너뛰고 있는 시스템의 함수 하나를 고르라. 하나의 metamorphic relation을 작성하라. 실행하라. 더 깊이 알고 싶다면 GraphicsFuzz 팀의 gfauto 툴링은 오픈소스이며, Segura 등의 ACM survey는 수십 개 도메인의 관계를 카탈로그화했다.
이 기법은 147개의 컴파일러 버그를 찾았고, 자동차 시뮬레이션 플랫폼의 확인된 결함을 찾았으며, 자율주행차의 인지 실패 유형이 도로에 나가기 전에 드러냈다. 버그는 실재한다. 유일한 질문은 당신이 그것을 찾고 있는가 하는 것이다.