고객 지원 티켓에 라벨을 붙이는 머신러닝 모델을 배포했습니다. 테스트 스위트는 초록색입니다. 모든 테스트가 통과했습니다.
그 테스트들 중 어느 것도 라벨이 올바른지 실제로 검사하지 않습니다. 올바른 라벨이 무엇인지 모릅니다. 아무도 모릅니다. 실제 입력에 대해 ‘올바른’ 출력은 실질적으로 알 수 없으므로, 함수가 충돌하지 않는지 또는 출력 형태가 예상과 일치하는지 확인하는 데 만족합니다. 그것은 테스트가 아닙니다. 희망일 뿐입니다.
이것이 바로 oracle problem이며, 이는 어디에나 나타납니다: 컴파일러, 시뮬레이션, 최적화 알고리즘, 사기 탐지기, 그리고 ground truth가 비싸거나 주관적이거나 계산 불가능한 모든 시스템에서. 예상 출력을 정의할 수 없을 때, 기존의 단위 테스트는 무너집니다.
metamorphic testing은 출구를 제공합니다. ‘이 출력이 올바른가?‘라고 묻는 대신, ‘관련된 입력이 알려진 관계를 만족하는 출력을 생성하는가?‘라고 묻습니다. 그렇지 않다면 코드는 고장 난 것입니다. 그렇다면, 정답을 전혀 모르면서도 실제 자신감을 얻은 것입니다.
metamorphic testing이란?
metamorphic testing은 1990년대 후반 Tsong Yueh Chen과 동료들이 좌절스러운 현실에 대응하여 제안했습니다: 많은 프로그램은 실제로 유용하지만 기존의 oracle로는 실질적으로 테스트할 수 없습니다.
핵심 아이디어는 간단합니다. metamorphic relation을 식별합니다: 프로그램의 여러 실행에서 입력과 출력 사이에 성립해야 하는 속성입니다. 소스 입력에서 프로그램을 실행하고, relation에 따라 입력을 변형한 다음, 다시 실행하고, 출력들이 속성이 예측하는 방식으로 서로 관련이 있는지 확인합니다.
예상 출력이 필요 없습니다. 인간 라벨러가 필요 없습니다. golden dataset이 필요 없습니다.
가중치 그래프에서 최단 경로를 찾는 함수를 생각해보세요:
from typing import List, Tuple, Optional
def shortest_path(
edges: List[Tuple[int, int, float]],
start: int,
end: int
) -> Optional[float]:
"""Dijkstra's algorithm. Returns path length or None if unreachable."""
import heapq
graph = {}
for u, v, w in edges:
graph.setdefault(u, []).append((v, w))
dist = {start: 0.0}
heap = [(0.0, start)]
while heap:
d, u = heapq.heappop(heap)
if u == end:
return d
if d > dist.get(u, float('inf')):
continue
for v, w in graph.get(u, []):
nd = d + w
if nd < dist.get(v, float('inf')):
dist[v] = nd
heapq.heappush(heap, (nd, v))
return None
복잡한 그래프에서 예상 최단 경로를 손으로 계산하는 것은 지루합니다. 하지만 반드시 성립해야 하는 여러 metamorphic relation을 알고 있습니다:
-
가중치에 대한 단조성. 단일 가중치를 증가시키면 최단 경로는 더 짧아져서는 안 됩니다. (해당 edge가 최적 경로에 없었다면) 동일하거나 길어질 수 있습니다.
-
스케일링에 대한 동차성. 모든 가중치를 양의 상수로 곱하면 최단 경로 길이도 동일한 상수만큼 스케일링되어야 합니다.
-
무방향 그래프에서의 경로 대칭성. 그래프가 무방향이라면 시작과 끝을 바꾸면 동일한 경로 길이를 얻어야 합니다.
이것은 휴리스틱이 아닙니다. 수학적 속성입니다. 이들 중 어느 것이라도 실패하면 구현은 틀린 것입니다. 이상입니다.
metamorphic test 작성하기
실제로 단조성 relation이 어떤 모습인지 보겠습니다:
import random
def test_shortest_path_monotonicity():
# Generate a random connected graph
nodes = list(range(10))
edges = []
for i in range(len(nodes) - 1):
edges.append((i, i + 1, random.uniform(1.0, 10.0)))
# Add some random cross edges
for _ in range(10):
u, v = random.sample(nodes, 2)
edges.append((u, v, random.uniform(1.0, 10.0)))
start, end = 0, 9
original = shortest_path(edges, start, end)
assert original is not None
# Increase the weight of one arbitrary edge
idx = random.randrange(len(edges))
u, v, w = edges[idx]
modified_edges = list(edges)
modified_edges[idx] = (u, v, w + 5.0)
modified = shortest_path(modified_edges, start, end)
assert modified is not None
assert modified >= original
이 테스트는 예상 최단 경로를 계산하지 않습니다. 그럴 필요가 없습니다. 구조적 속성이 성립하는지 확인하며, 이것만으로도 놀랄 만큼 다양한 버그를 잡아낼 수 있습니다: 부호 오류, 가중치 누산에서의 off-by-one 실수, 잘못된 우선순위 큐 정렬 등.
스케일링 relation은 테스트하기가 더 간단합니다:
def test_shortest_path_scaling():
nodes = list(range(8))
edges = []
for i in range(len(nodes) - 1):
edges.append((i, i + 1, random.uniform(2.0, 5.0)))
start, end = 0, 7
original = shortest_path(edges, start, end)
factor = 3.5
scaled_edges = [(u, v, w * factor) for u, v, w in edges]
scaled = shortest_path(scaled_edges, start, end)
assert abs(scaled - original * factor) < 1e-9
부동소수점 허용 오차에 주목하세요. metamorphic test는 수치 정밀도 문제에 면역이 아니므로, 다른 수치 테스트에서 사용하는 것과 동일한 주의로 assertion을 작성하세요.
이 접근법이 실제로 도움이 되는 곳
metamorphic testing은 기존의 oracle이 약하거나 존재하지 않는 영역에서 빛을 발합니다.
머신러닝. 영화 리뷰의 정확한 감정 점수는 모르지만, ‘terrible’이라는 단어를 추가했을 때 긍정적인 감정이 증가해서는 안 된다는 것은 압니다. 객체 탐지기의 정확한 bounding box는 모르지만, 이미지를 수평으로 뒤집으면 bounding box 좌표도 뒤집혀야 한다는 것은 압니다.
컴파일러. 모든 가능한 프로그램에 대해 최적화된 바이너리가 최적화되지 않은 바이너리와 정확히 동일한 출력을 생성하는지 검증하는 것은 불가능합니다. 하지만 프로그램을 컴파일한 다음, no-op 변환(변수 이름 바꾸기 같은)으로 다시 컴파일하면 의미적으로 동등한 바이너리가 생성되는지 확인할 수 있습니다.
과학 컴퓨팅. 복잡한 시뮬레이션에서 입자의 정확한 궤적은 모르지만, 시간을 되돌리면 궤적도 되돌아가야 한다는 것은 압니다. 분자의 정확한 기저 상태 에너지는 모르지만, 기저 집합 크기를 늘리면 감소(또는 일정)해야 한다는 것은 압니다.
모든 경우에서 통찰은 동일합니다: 정확성이 항상 단일 예상 값과 일치하는 것은 아닙니다. 때로는 변형에 걸쳐 구조를 보존하는 것입니다.
트레이드오프와 한계
metamorphic testing은 공짜가 아니며, 다른 모든 종류의 테스트를 대체하는 것도 아닙니다.
relation은 불완전할 수 있습니다. 프로그램이 정의한 모든 metamorphic relation을 통과해도 여전히 틀릴 수 있습니다. relation 집합이 특정 버그 클래스를 다루지 않는다면, 그 버그는 빠져나갑니다. 이것이 커버리지 문제이며, 실제로 존재합니다.
relation은 틀릴 수 있습니다. 속성이 성립하지 않을 때 성립한다고 잘못 주장하면, 테스트는 false positive 공장이 됩니다. 한 팀이 k-means 클러스터링이 feature scaling에 대해 불변해야 한다고 주장하는 것을 본 적이 있습니다. 그렇지 않습니다. 중심점은 데이터와 함께 스케일링됩니다. 테스트 데이터에서 스케일링 계수가 우연히 1.0이었기 때문에 테스트가 수 개월 동안 통과했습니다. 실제 데이터가 도착했을 때, 모델은 조용히 저하되었고 테스트는 여전히 초록색이었습니다.
실패 디버깅이 더 어렵습니다. 기존의 단위 테스트가 실패하면 예상 출력이 정확히 무엇이었는지 압니다. metamorphic test가 실패하면 relation이 위반되었다는 것은 알지만, 여전히 올바른 출력은 모릅니다. 속성 위반으로부터 근본적인 버그까지 역으로 추론해야 하며, 이는 더 많은 작업일 수 있습니다.
테스트 데이터 생성이 중요합니다. 무작위 그래프, 무작위 문장, 무작위 이미지는 실제 입력을 대표하지 않습니다. 합성 데이터에서의 metamorphic test는 통과할 수 있지만, 생성기가 결코 생성하지 않은 엣지 케이스에서 프로덕션 시스템이 실패할 수 있습니다. Hypothesis와 같은 property-based testing 라이브러리를 사용하여 도움을 받되, 자신의 생성기에 대해 회의적으로 생각하세요.
오늘부터 시작하는 방법
새로운 프레임워크가 필요 없습니다. 세 가지만 필요합니다:
-
약한 oracle을 가진 함수 하나를 선택하세요. 모델 추론 메서드, 기하학적 계산, 시뮬레이션 단계. 현재 ‘충돌하지 않는다’만 테스트하고 더 하고 싶은 곳.
-
세 가지 relation을 브레인스토밍하세요. 물어보세요: 어떤 변환이 출력을 변하지 않게 해야 하나요? 어떤 변환이 출력을 예측 가능한 방식으로 변경해야 하나요? 어떤 입력 쌍이 관련된 출력을 생성해야 하나요? 뻔해 보여도 적어두세요.
-
하나의 relation을 테스트로 구현하세요. 무작위 입력에서 실행하세요. 실패하면 버그를 찾았거나 틀린 relation을 찾은 것입니다. 둘 다 가치 있습니다.
앞서 언급한 k-means 예제에서, 올바른 relation 집합은 다음과 같습니다:
import numpy as np
from sklearn.cluster import KMeans
def test_kmeans_translation_invariance():
X = np.random.rand(100, 3)
shift = np.array([10.0, -5.0, 2.0])
km1 = KMeans(n_clusters=3, random_state=42, n_init=10).fit(X)
km2 = KMeans(n_clusters=3, random_state=42, n_init=10).fit(X + shift)
# Centroids should differ by exactly the shift vector
np.testing.assert_allclose(km1.cluster_centers_ + shift, km2.cluster_centers_)
클러스터링 로직이 좌표계를 잘못 처리하면 이 테스트는 실패하며, 이는 올바른 중심점이 무엇인지 단언하지 않고도 이루어집니다.
정답을 알 수 없을 때는 구조를 테스트하라
oracle problem은 테스트의 엣지 케이스가 아닙니다. 이는 엄청나게 많은 유용한 소프트웨어의 기본 상태입니다. metamorphic testing은 이를 완전히 해결하지는 않지만, ‘이것은 테스트할 수 없다’에서 ‘중요한 속성은 테스트할 수 있다’로 이동시킵니다.
하나의 relation, 하나의 함수, 그리고 그것이 잡아내는 하나의 실제 버그로 시작하세요. 그것만으로도 이 접근법을 정당화하기에 충분합니다. 나머지는 그저 relation을 더 추가하는 것뿐입니다.