100번 테스트 실행은 거짓말이다: Property-Based Test를 실제로 사이징하는 법
property-based test를 기본값 100개 예제로 실행하고 있다면, 양쪽 모두 최악의 상황을 겪고 있는 것이다. CI는 필요 이상으로 느리고, 여전히 중요한 버그는 놓치고 있다.
이 숫자에 마법 같은 건 없다. Hypothesis를 포함한 대부분의 라이브러리가 100을 기본값으로 정한 이유는 그저 둥근 숫자라서 안전하게 느껴지기 때문이다. 하지만 “안전하게 느껴진다”는 건 테스트 전략이 아니다.
property-based testing이 실제로 보장하는 것
property-based testing은 단위 테스트의 패러다임을 뒤집는다. 입력과 기대 출력을 손으로 일일이 작성하는 대신, property를 정의한다. 항상 성립해야 하는 규칙이다. 프레임워크는 이 규칙을 깨뜨릴 입력을 생성한다.
from hypothesis import given, strategies as st
@given(st.lists(st.integers()))
def test_reversing_a_list_twice_gives_the_original(lst):
assert lst == list(reversed(list(reversed(lst))))
프레임워크는 이 함수를 정수 리스트의 랜덤 입력으로 여러 번 실행한다. 반례를 찾으면, 입력을 축소(shrinking)해 여전히 실패하는 가장 작은 버전으로 만든다. 버그를 트리거하는 47개 요소의 리스트는 디버깅에 쓸모없다. 3개 요소의 리스트가 진짜 복덩이다.
이건 강력하다. 동시에 확률적이다. property-based testing은 정확성을 증명할 수 없다. 버그가 없을 가능성을 높이거나, 버그가 있다면 찾아낼 수 있을 뿐이다. 이런 확률적 본질이 실행 횟수를 그렇게 중요하게 만든다.
왜 100은 임의적인가
100이 어디서 왔는지 솔직해지자. Hypothesis에서 이건 2015년에 정한 기본값으로, 대부분의 버그를 잡으면서도 테스트를 너무 느리게 만들지 않는 둥근 숫자였다. 통계적 타협이 아닌 사회적 타협이었다.
버그를 찾을 확률은 두 가지에 의존한다. 입력 공간에서 그 버그가 얼마나 흔한지, 그리고 얼마나 많은 샘플을 뽑는지. 버그가 길이 20 이상의 회문(palindrome)에서만 발생하고, 회문이 전체 리스트의 0.01%라면, 100번 실행으로 찾을 확률은 대략 1%다. 이건 테스트가 아니다. 복권이다.
대부분의 버그는 그렇게 드물지 않다. 많은 property는 빈 리스트, 단일 요소, 또는 단순한 중복에서 깨진다. 잘 튜닝된 generator는 이런 걸 빠르게 잡아낸다. 하지만 100이라는 기본값은 generator가 완벽하고 버그가 얕게 숨어 있다고 가정한다. 두 가정 모두 틀렸다.
통계적으로 실행 횟수가 실제로 사주는 것
버그 발견을 버그가 확률 p로 등장하는 입력 공간에서 복원 추출로 모델링하면, n번 실행 후에도 버그를 놓칠 확률은 (1 - p)^n이다.
p = 0.01일 때, 100번 실행은 37%의 버그 놓칠 확률을 남긴다. p = 0.001일 때, 100번 실행은 90%의 놓칠 확률을 남긴다. 0.1% 버그를 99% 확신으로 잡으려면 약 4,600번 실행이 필요하다.
import math
def runs_for_confidence(p, confidence=0.99):
"""Returns the runs needed to catch a bug with probability `p`
at the given confidence level."""
return math.ceil(math.log(1 - confidence) / math.log(1 - p))
print(runs_for_confidence(0.01)) # 459
print(runs_for_confidence(0.001)) # 4603
print(runs_for_confidence(0.0001)) # 46050
이 부분이 사람들을 불편하게 만든다. 드문 버그에 높은 신뢰를 얻고 싶다면 수만 번의 실행이 필요하다. CI에서 그걸 기다리고 싶어하는 사람은 없다.
shrinking이 비용 공식을 바꾼다
100번 실행이라는 기본값은 shrinking이 지금만큼 좋지 않았을 때 정해졌다. 현대의 property-based testing 프레임워크는 버그를 찾기만 하는 게 아니다. 최소한의 버그를 찾는다.
이건 횟수가 아닌 예산으로 생각할 수 있다는 뜻이다. 1,000개 예제를 실행해서 847번째에서 버그를 찾았다면, shrinking은 최소 counterexample을 만드는 데 또 200~300번의 실행을 더 할 수 있다. 한 버그에 총 1,100번 이상의 실행 비용이 드는 셈이다. 하지만 10,000개 예제를 실행해서 아무것도 찾지 못했다면, 10,000번 실행은 마음의 평화를 산 셈이다.
비결은 discovery와 validation을 분리하는 것이다. PR에서 즉각적인 피드백을 위해 작고 빠른 suite를 실행한다. 릴리즈 브랜치나 야간에 더 크고 느린 suite를 실행해 깊은 신뢰를 얻는다.
from hypothesis import given, settings, strategies as st
import json
# Fast feedback in CI
@given(st.dictionaries(st.text(), st.integers()))
@settings(max_examples=100)
def test_json_roundtrip_fast(d):
assert json.loads(json.dumps(d)) == d
# Deeper confidence on main
@given(st.dictionaries(st.text(), st.integers()))
@settings(max_examples=5000, deadline=None)
def test_json_roundtrip_thorough(d):
assert json.loads(json.dumps(d)) == d
이건 단순히 속도의 문제가 아니다. 정보 밀도의 문제다. 통과한 100번 실행 테스트는 거의 아무것도 알려주지 않는다. 통과한 5,000번 실행 테스트는 약간 더 많은 걸 알려준다. 실패한 100번 실행 테스트는 정확히 어디를 봐야 할지 알려준다.
property-based test를 티어로 나누는 방법
우리의 경험상, 가장 좋은 접근법은 모든 property를 동등하게 다루는 걸 멈추는 것이다. 세 개의 티어로 나눈다.
Fast properties는 모든 pull request에서 실행된다. 기계적인 것들이다. round-trip serialization, deduplication의 idempotency, data structure의 기본 invariants. 100~200개 예제를 실행한다. 1초도 안 걸려 완료된다.
Deep properties는 main에 머지할 때마다 실행된다. 복잡한 state machine, event processing pipeline, combinatorial explosion이 있는 모든 것을 타겟팅한다. 2,000~10,000개 예제를 실행한다. 분 단위이지 시간 단위는 아니다.
Exploratory properties는 릴리즈 전에 수동으로 실행한다. max_examples를 50,000 이상으로 올리고 기계가 돌아가는 동안 changelog를 검토하는 것들이다. 이런 방식으로 race condition이나 integer overflow edge case를 찾아냈는데, 이건 얼마의 단위 테스트로도 잡아내지 못했을 것이다.
추측 대신 해야 할 일
max_examples를 한 번 설정하고 잊어버리는 다이얼로 다루는 걸 멈춰라. 이건 프레임워크가 아닌 property에 속하는 설정으로 다뤄야 한다.
작성하는 모든 property에 대해 세 가지 질문을 하라.
이 테스트를 실행하는 데 비용이 얼마나 드는가? 예제 하나가 50ms라면 10,000번 실행은 8분이다. 5ms라면 1분도 안 된다.
버그를 놓쳤을 때 피해가 얼마나 큰가? log message의 formatting 버그와 payment pipeline의 data corruption 버그는 같지 않다.
트리거 조건이 얼마나 드문가? 버그가 윤년에만, 또는 두 UUID가 충돌할 때만, 또는 정확히 INT_MAX에서만 발생한다면, 더 많은 실행이나 더 똑똑한 generator가 필요하다.
더 똑똑한 generator가 거의 항상 더 많은 실행을 이긴다. JSON parser를 테스트한다면, 랜덤 문자열을 생성해서 parsing되길 바라지 마라. 유효한 객체를 생성하고 돌연변이시켜라.
from hypothesis import given, settings, strategies as st
import json
# Bad: most random strings aren't valid JSON
@settings(max_examples=10000)
@given(st.text())
def test_parse_json_bad(s):
try:
json.loads(s)
except json.JSONDecodeError:
pass # Most inputs hit this immediately
# Good: generate valid objects, then edge cases
@settings(max_examples=500)
@given(st.dictionaries(st.text(), st.integers()))
def test_parse_json_good(d):
assert json.loads(json.dumps(d)) == d
좋은 generator로 500번 실행하면 나쁜 generator로 10,000번 실행하는 것을 매번 이긴다.
property-based test 사이징에 대한 흔한 질문
더 많은 실행은 항상 더 나은 coverage를 의미하는가?
꼭 그렇지는 않다. property-based testing은 전통적인 의미의 coverage metric을 갖지 않는다. 더 많은 실행은 버그를 찾을 확률을 높이지만, 수확 체감은 빠르게 온다. 100번에서 200번으로 두 배 늘리는 건 의미 있다. 10,000번에서 20,000번으로 두 배 늘리는 건 거의 의미 없다.
fuzzing은 어떤가? 그냥 수백만 번 실행하는 property-based testing 아닌가?
fuzzing은 인접하지만 다르다. fuzzer는 일반적으로 도메인에 대한 semantic 이해 없이 수백만 개의 입력을 실행한다. property-based testing은 structured generator와 shrinking을 사용한다. PBT를 smart fuzzing으로, fuzzing을 brute-force PBT로 생각할 수 있다. 실행당 비용과 실행당 정보가 다르기 때문에 실행 횟수 계산법도 다르다.
max_examples는 CI에서 높게, 아니면 낮게 설정해야 하는가?
CI에서는 높게, 로컬 개발에서는 낮게. 노트북은 속도를 위한 것이다. CI는 신뢰를 위한 것이다. settings profile이나 환경 변수로 전환하라.
import os
from hypothesis import settings
CI = os.environ.get("CI", "false").lower() == "true"
settings.register_profile("ci", max_examples=5000, deadline=None)
settings.register_profile("dev", max_examples=100)
settings.load_profile("ci" if CI else "dev")
내 generator가 충분히 좋은지 어떻게 알 수 있는가?
max_examples를 매우 높게, 예를 들어 50,000으로 설정하고 테스트를 실행하면서 coverage 리포트를 지켜보라. 브랜치가 누락되어 있다면 generator가 그 브랜치를 실행하지 않고 있다는 뜻이다. 실행 횟수를 낮추기 전에 generator를 고쳐라.
완벽한 실행 횟수를 찾는 걸 멈추고 측정을 시작하라
property-based testing에 대한 보편적으로 올바른 실행 횟수는 없다. 존재하는 건 당신의 property, generator, CI 예산, 그리고 방지하려는 버그의 비용에 맞는 올바른 숫자뿐이다.
어쩔 수 없다면 100부터 시작하라. 하지만 critical path를 지키는 property는 늘리고, 단순 sanity check인 property는 줄여라. 테스트가 얼마나 걸리는지 측정하라. generator를 프로파일링하라. 그리고 기억하라: 100번 통과한 property-based test는 증명이 아니다. 그냥 증거일 뿐이다.
더 깊이 가고 싶다면, Hypothesis의 test statistics와 targeted property-based testing 문서를 읽을 가치가 있다. hypothesis CLI는 테스트가 어떤 예제에 시간을 쓰고 있는지 정확히 보여준다. 다이얼을 올릴지 내릴지 결정할 때 가장 먼저 봐야 할 곳이다.