가격 책정 엔진에 대해 12개의 metamorphic relation을 작성했습니다. 모든 테스트가 통과합니다. 커버리지에 대해 기분이 좋습니다.

그런 다음 고객이 대량 할인이 거꾸로 계산된다고 보고합니다. relation 스위트를 확인합니다. 단 하나의 테스트도 실패하지 않았습니다. 가산적 일관성, 단조성, 멱등성에 대한 relation이 있었습니다. 이들 중 어느 것도 할인 배율의 부호 오류를 잡아내지 못했습니다.

이것이 metamorphic testing의 은밀한 비밀입니다: relation을 가지는 것과 유용한 relation을 가지는 것은 다릅니다. 약한 metamorphic relation은 테스트가 전혀 없는 것보다 나쁩니다. 코드가 올바르지 않을 때 올바르다고 믿게 만들기 때문입니다.

‘좋은’ relation을 만드는 것은 무엇인가?

좋은 metamorphic relation은 높은 실패 탐지 능력을 가집니다. 프로그래머가 실제로 작성하는 실제 버그를 잡아냅니다. 나머지는 그저 오버헤드입니다.

고전적인 예는 off-by-one 버그가 있는 평균 함수를 테스트하는 것입니다:

def buggy_mean(values):
    """Compute the arithmetic mean."""
    return sum(values) / (len(values) - 1)  # bug: off-by-one in denominator

표본 분산 공식에 익숙하다면 그럴듯해 보입니다. 하지만 틀렸습니다. 다음은 사람들이 평균 함수에 대해 흔히 작성하는 네 가지 relation과 각각이 실제로 잡아내는 것입니다:

  1. 유계성: 평균은 최소값과 최대값 사이에 있습니다. 약함. 버그 있는 평균 함수도 대부분의 입력에서 이를 만족합니다.

  2. 상수에 대한 멱등성: mean([c] * n) == c. 중간. 상수 리스트에서는 버그를 잡아내지만, 무작위 데이터에서는 거의 실패를 유발하지 않습니다.

  3. 평행 이동 불변성: mean([x + c for x in values]) == mean(values) + c. 강함. 버그 있는 분모는 거의 모든 비어 있지 않은 입력에서 이를 깨뜨립니다.

  4. 스케일링: mean([x * k for x in values]) == mean(values) * k. 강함. 같은 이유입니다. off-by-one이 스케일링에서 흥미로운 경우에 단 한 번도 살아남지 못합니다.

테스트 스위트가 유계성과 상수 멱등성만 검사했다면, off-by-one은 프로덕션으로 유유히 들어갔을 것입니다. metamorphic test는 있었을 것입니다. 버그 탐지는 없었을 것입니다.

강한 relation 대 약한 relation

강한 relation과 약한 relation의 차이는 얼마나 영리하게 들리는지가 아닙니다. 얼마나 많은 결함 클래스를 제거하는지입니다.

약한 relation은 대부분의 잘못된 구현이 어차피 우연히 만족하는 속성을 검사합니다. 유계성이 완벽한 예입니다. 대부분의 산술 버그는 유계성을 보존합니다. 덧셈과 곱셈이 입력 범위 밖의 값을 자발적으로 만들지 않기 때문입니다. 고장 난 코드에서도 통과하는 relation은 보여주기식에 불과합니다.

강한 relation은 고장 난 구현이 위반하는 구조적 제약을 인코딩합니다. 평행 이동 불변성이 강한 이유는 입력 변환을 정확한 등식을 통해 출력 변환에 묶기 때문입니다. 흔들 여지가 없습니다.

이것을 형식적으로 측정할 수 있습니다. metamorphic testing 연구에서, relation **포함(subsumption)**은 relation A가 relation B가 탐지하는 모든 결함과 그 외의 것까지 탐지함을 의미합니다. A가 B를 포함한다면, B는 중복입니다. A를 유지하고 B를 삭제해야 합니다.

실제로는 형식적 증명이 필요 없습니다. 직관이 필요합니다: 고의로 그럴듯한 버그를 도입한 후에도 relation이 여전히 통과한다면, 그것은 약한 것입니다. 버리세요.

좋은 relation은 다른 결함 영역을 커버한다

하나의 강한 relation으로는 충분하지 않습니다. 단일 relation은 한 클래스의 실수만 잡습니다. 실제 프로그램은 여러 독립적인 버그 타입을 포함하며, relation 집합이 이들을 커버해야 합니다.

정렬 함수를 생각해보세요. 다음은 각각이 잡아내는 것에 따라 순위를 매긴 relation들입니다:

순열: 출력이 입력과 정확히 동일한 요소를 포함합니다. 누락/중복 버그를 잡습니다. 정렬 버그는 놓칩니다.

순서: 출력이 비감소입니다. 비교 버그를 잡습니다. 순열 버그는 놓칩니다.

멱등성: sort(sort(x)) == sort(x). 정렬을 파괴하는 진정으로 고장 난 구현만 잡습니다. 거의 쓸모없습니다.

안정성: 각 요소를 원래 인덱스와 짝지으면, 동일한 키는 입력 순서를 유지합니다. > 대신 >=를 사용하는 비교 연산자를 잡습니다.

부분 구조: 접두사를 정렬한 다음 전체 리스트를 정렬하면 접두사 순서가 일치해야 합니다. 조기 종료 버그를 잡습니다.

순열과 멱등성만 있는 테스트 스위트는 항상 [1, 2, 3]을 반환하는 정렬을 놓칠 것입니다. 순열과 순서가 있는 스위트는 그 버그를 잡습니다. 안정성을 추가하면 불안정한 정렬도 잡습니다.

핵심은 가능한 한 많은 relation을 수집하는 것이 아닙니다. 핵심은 독립적인 실패 모드를 커버하는 것입니다. 동일한 버그를 잡는 두 relation은 다른 버그를 잡는 하나의 relation보다 나쁩니다.

트레이드오프: 더 강한 relation을 찾기는 더 어렵다

팀이 약한 relation을 작성하는 이유가 있습니다. 강한 relation은 도메인 지식이 필요합니다. 문제의 수학적 구조를 충분히 잘 이해하여 분명하지 않은 불변성을 인코딩할 수 있어야 합니다.

평균 함수의 경우, 통계학 배경이 있는 사람이라면 평행 이동 불변성이 분명합니다. 입자 시뮬레이션의 경우, 동등한 relation은 Hamiltonian 역학이 위상 공간 부피를 보존한다는 것을 알아야 할 수도 있습니다. 모든 팀이 그런 전문 지식을 갖고 있는 것은 아닙니다.

다른 비용은 디버깅입니다. 강한 relation이 실패하면, 위반은 어떤 구조적 속성이 깨졌는지 알려주지만, 버그는 그 속성에 이르는 추론 체인의 어디에나 있을 수 있습니다. ‘출력 길이는 입력 길이와 같다’와 같은 약한 relation은 정확히 한 가지 방식으로만 실패합니다. ‘이동된 신호의 푸리에 변환은 선형 위상 항을 획득한다’와 같은 강한 relation은 백 가지 방식으로 실패할 수 있으며, 어떤 것이 버그인지 추적하는 데 더 오래 걸립니다.

이것이 핵심적인 긴장입니다. 약한 relation은 쓰기 쉽고, 디버깅하기 쉽고, 대부분 쓸모없습니다. 강한 relation은 쓰기 어렵고, 디버깅하기 어렵고, 실제로 버그를 찾습니다. 공짜 점심은 없습니다.

metamorphic relation 평가하기

relation을 테스트 스위트에 추가하기 전에, 세 가지 검사를 통과시키세요:

고의적 버그 테스트. 구현에 현실적인 버그를 도입하세요. relation이 실패하나요? 그렇지 않다면, relation은 제 몫을 하고 있지 않은 것입니다. 부호 오류, off-by-one, 인자 교환, 누락된 경계 조건을 시도해보세요. 이것들은 프로덕션에서 발생하는 버그입니다. relation이 이들을 잡아내야 합니다.

독립성 테스트. 기존 relation을 살펴보세요. 이들 중 어느 것이 동일한 버그를 잡을 수 있나요? 그렇다면, 이 새로운 relation은 중복입니다. 중복은 안전이 아닙니다. 한계 효용 없는 유지보수 부담입니다.

반증 가능성 테스트. relation을 만족하는 그럴듯한 고장 난 구현을 상상할 수 있나요? 30초 안에 하나를 스케치할 수 있다면, relation은 너무 약합니다. 좋은 relation은 느슨한 제안이 아니라 단단한 제약처럼 느껴져야 합니다.

평균 함수에 대한 코드에서는 다음과 같습니다:

import random

def mean(values):
    return sum(values) / len(values)

def test_translation_invariance():
    values = [random.uniform(-100, 100) for _ in range(20)]
    c = 5.5
    shifted = [x + c for x in values]
    assert mean(shifted) == mean(values) + c

def test_scaling():
    values = [random.uniform(-50, 50) for _ in range(20)]
    k = 3.0
    scaled = [x * k for x in values]
    assert mean(scaled) == mean(values) * k

이제 off-by-one 버그를 도입하세요. len(values)len(values) - 1로 변경하세요. 두 테스트를 실행하세요. 평행 이동 불변성은 즉시 실패합니다. 스케일링은 즉시 실패합니다. 유계성은 아마 통과할 것입니다.

이것이 스위트에서 제 몫을 하는 relation과 단지 줄 수만 차지하는 relation의 차이입니다.

속성이 아닌 결함 클래스로 시작하라

대부분의 팀이 하는 실수는 먼저 속성을 브레인스토밍하는 것입니다. ‘이 함수에는 어떤 불변성이 있나요?‘라고 묻습니다. 이는 약한 relation을 만듭니다. 불변성은 말하기 쉽고 위반하기 어렵기 때문입니다.

대신, 결함 클래스로 시작하세요. ‘피곤한 프로그래머가 이 함수에 어떤 버그를 작성할까요?‘라고 물으세요. 그런 다음 그 버그를 잡는 relation을 찾으세요.

기하학적 거리 함수의 경우, 가능성 있는 버그는 부호 오류, 단위 혼동, 차원 불일치입니다. 거리가 음수가 아닌지 검사하는 relation은 부호 오류를 잡습니다. 좌표 변환에서 스케일링을 검사하는 relation은 단위 혼동을 잡습니다. 삼각 부등식을 검사하는 relation은 차원의 무의미함을 잡습니다.

relation이 잡는 버그의 이름을 지을 수 없다면, 그 relation은 필요 없습니다.

Relation은 희소한 자원이다. 현명하게 사용하라.

metamorphic testing은 커버리지 지표가 아닙니다. 자신감입니다. 실제 버그를 잡는 하나의 강한 relation은 고장 난 코드에서도 통과하는 20개의 약한 relation보다 가치 있습니다.

기존 metamorphic test를 감사하세요. 버그를 도입하세요. 무엇이 실패하는지 확인하세요. 실패하지 않는 것은 삭제하세요. 그런 다음 실제로 걱정하는 각 결함 클래스에 대해 하나의 relation을 추가하세요. 그것이 제 몫을 하는 테스트 스위트입니다.