python hypothesis 사용법, 속성 기반 테스트로 찾은 반례

Hypothesis 속성 기반 테스트 입문, 커버리지 100%에서 찾은 반례

3줄 요약

  • 예제 기반 테스트 12개가 모두 통과하고 coverage.py 분기 커버리지가 100%인 모듈에 속성 기반 테스트 4개를 붙였더니 3개가 즉시 반례를 냈습니다.
  • 반례 탐지에 필요한 max_examples는 25면 충분했고(세 속성 모두 시드 30개 중 30개 탐지), 비용은 속성 4개 기준 0.311초로 예제 12개의 0.132초 대비 0.179초 증가였습니다.
  • 오늘 붙일 계획이라면 속성을 늘리기 전에 CI 환경변수가 있을 때 기본값 다섯 개가 한꺼번에 바뀐다는 것부터 확인하시면 됩니다.

기기 두 대까지는 아무 일도 없었다는 식의 이야기를 저는 커버리지 숫자에서도 한 번 했습니다. 주문 알림에 쓰는 문자열·구간 유틸리티 네 개를 정리하면서 예제 테스트 12개를 붙였고, coverage.py를 --branch로 돌려 statement 22개와 분기 8개가 전부 덮인 것을 확인했습니다. 미커버 0. 그 화면을 보고 저는 이 모듈은 끝났다고 생각했습니다.

틀렸습니다. 같은 모듈에 속성 기반 테스트 네 개를 얹는 데 20분이 걸렸고, 그중 세 개가 0.6초 만에 반례를 냈습니다. 더 나빴던 것은 반례 하나가 제가 쓴 예제 테스트에 정답으로 박혀 있었다는 사실입니다. 테스트가 버그를 지키고 있었습니다.

이 글은 Hypothesis 6.168.0을 처음 붙여 본 하루치 기록입니다. 실행 환경은 Python 3.10.12, pytest 9.1.1, pytest-xdist 3.8.0, Ubuntu 22.04 aarch64 4코어이고, 버전은 PyPI JSON API의 upload_time_iso_8601 기준 2026-09-08T18:47:37.076523Z(KST 09-09 03:47) 배포분입니다.

1. 커버리지가 채우는 것과 속성이 채우는 것은 애초에 다른 축입니다

출발점 — 예제 12개, 분기 커버리지 100%

대상은 네 함수입니다. 이메일 로컬파트를 가리는 mask_email, 페이지 번호를 offset으로 바꾸는 paginate, 겹치는 구간을 합치는 merge_ranges, 말줄임을 붙이는 truncate. 예제 테스트는 각 함수당 세 개 안팎으로 12개를 썼고, 값이 명확한 경우만 골라 담았습니다.

Name              Stmts   Miss Branch BrPart  Cover
---------------------------------------------------
src/textutil.py      22      0      8      0   100%

12 passed in 0.26s. 여기까지는 평범한 하루의 결과물입니다.

속성 네 개를 붙인 결과

속성 기반 테스트는 "이 입력이면 이 값"이 아니라 "어떤 입력이든 이 규칙은 깨지지 않는다"를 적습니다. 네 함수에 대해 제가 적은 규칙은 이렇습니다. 마스킹은 글자 수를 바꾸지 않는다. offset은 항상 total보다 작다. 병합 전에 덮여 있던 정수는 병합 후에도 덮여 있다. 결과 길이는 limit를 넘지 않는다.

from hypothesis import given, strategies as st, settings
from textutil import paginate, merge_ranges

@given(st.integers(1, 10_000), st.integers(1, 500), st.integers(-5, 10_000))
@settings(max_examples=200, derandomize=True)
def test_prop_paginate_offset_in_range(total, per_page, page):
    """offset은 항상 total 미만이어야 합니다. 넘으면 빈 페이지를 받습니다."""
    assert paginate(total, per_page, page)["offset"] < total

# 겹치는 [start, end) 구간 리스트를 만드는 전략
RANGES = st.lists(st.tuples(st.integers(0, 50), st.integers(0, 50)).map(
    lambda t: (min(t), max(t) + 1)), max_size=6)

@given(RANGES)
@settings(max_examples=200, derandomize=True)
def test_prop_merge_keeps_every_point(ranges):
    """병합 전에 덮인 정수는 병합 후에도 덮여 있어야 합니다."""
    before = {x for s, e in ranges for x in range(s, e)}
    after = {x for s, e in merge_ranges(ranges) for x in range(s, e)}
    assert before <= after

결과는 3 failed, 1 passed in 1.31s였습니다.

속성 축소된 반례 원인
offset < total total=1, per_page=1, page=2 페이지 수를 total // per_page + 1로 세어 마지막 한 페이지가 항상 빈 페이지
병합 전후 덮임 보존 ranges=[(1, 2), (0, 1)] 입력이 정렬돼 있다고 가정하고 한 번만 훑음
결과 길이 ≤ limit s='0', limit=0 s[: limit - 1]이 limit=0에서 s[:-1]이 되어 말줄임표만 남지 않고 길이가 늘어남
마스킹이 길이 보존 (통과) 전략이 좁아서 놓쳤습니다. 3절에서 다시 다룹니다

테스트가 버그를 정답으로 고정하고 있었습니다

paginate를 올림 나눗셈으로 고치고, 기존 예제 12개를 그대로 다시 돌렸습니다.

>       assert paginate(100, 20, 999)["offset"] == 100
E       assert 80 == 100
1 failed, 11 passed in 0.06s

총 100건을 20개씩 나누면 페이지는 다섯 개이고 마지막 페이지의 offset은 80입니다. 그런데 제 예제 테스트는 100을 기대하고 있었습니다. 구현을 먼저 쓰고 그 결과를 보면서 기대값을 채웠기 때문입니다. 커버리지는 이 줄이 실행됐다는 것만 알려 주지, 기대값이 옳은지는 모릅니다.

같은 성질을 뮤테이션 테스팅 쪽에서 한 번 확인한 적이 있는데, 줄 커버리지가 100%인 모듈에서 뮤턴트 35개가 살아남은 기록과 원인이 같습니다. 어서션이 약하면 실행 여부는 늘어나도 검증은 늘어나지 않습니다.

2. 축소가 없으면 반례를 읽을 수 없습니다

같은 시드에서 축소만 끄고 비교했습니다

Hypothesis가 반례를 찾으면 그 자리에서 멈추지 않고, 실패를 유지하는 가장 단순한 입력까지 깎아 냅니다. 이 단계를 phases에서 빼면 처음 걸린 입력이 그대로 보입니다. 같은 derandomize=True 조건에서 두 벌을 돌려 비교했습니다.

속성 축소 끔 (처음 걸린 입력) 축소 켬 (보고되는 반례)
offset < total total=74, per_page=74, page=564 total=1, per_page=1, page=2
덮임 보존 [(10,41),(30,48),(5,42),(13,40),(30,40),(27,46)] [(1, 2), (0, 1)]
길이 ≤ limit s='\x89ùñ\x88\x07\U000806c1\U0004e3ed y\x9e\x1c\x96', limit=0 s='0', limit=0

왼쪽 열을 받았다면 저는 원인을 찾는 데 한참 걸렸을 것입니다. 구간 여섯 개 중 어느 쌍이 문제인지, 12자짜리 유니코드 문자열의 어느 성질이 걸린 것인지 알 수 없기 때문입니다. 오른쪽 열은 읽는 순간 원인이 보입니다. 구간 두 개가 뒤집혀 있고, limit가 0입니다.

잡은 반례는 @example로 못 박습니다

축소된 반례는 그대로 회귀 테스트가 됩니다. @example을 붙이면 그 입력은 난수 생성과 무관하게 매번 먼저 실행됩니다.

from hypothesis import given, example, strategies as st

@given(st.integers(1, 10_000), st.integers(1, 500), st.integers(-5, 10_000))
@example(total=1, per_page=1, page=2)      # 실제로 잡힌 반례를 고정합니다
@settings(max_examples=200, deadline=None)
def test_pag(total, per_page, page):
    assert paginate(total, per_page, page)["offset"] < total

저는 반례를 잡을 때마다 이 한 줄을 같이 넣는 쪽을 택했습니다. 실패 사례 저장소(4절)는 로컬 디렉터리에 있어서 CI 컨테이너에서는 비어 있고, 팀원 노트북에도 없습니다. 코드에 박아 둔 @example만 저장소를 따라다닙니다.

3. 전략이 좁으면 반례가 아예 나오지 않습니다

같은 속성, 전략만 세 가지

1절에서 mask_email만 통과했습니다. 제가 쓴 전략이 소문자 로컬파트에 @example.com을 붙이는 형태였기 때문입니다. 속성은 그대로 두고 입력 전략만 바꿔 시드 1~10으로 돌렸습니다.

입력 전략 반례 검출 축소된 반례
소문자 1~20자 + 고정 도메인 0 / 10 —
st.emails() (내장 이메일 전략) 0 / 10 —
st.text(max_size=30) (임의 문자열) 10 / 10 addr=''

mask_email('')은 '@'를 돌려줍니다. 입력이 0자인데 출력이 1자입니다. @가 없는 문자열을 넣어도 마찬가지로 @가 하나 붙어 나옵니다.

여기서 배운 것은 전략을 넓히라는 단순한 교훈이 아닙니다. st.emails()조차 이 버그를 못 잡았다는 점이 중요합니다. 형식이 맞는 입력만 들어오는 전략은 형식이 틀린 입력에 대한 계약을 검증하지 못합니다. 그러니 함수가 실제로 어떤 입력을 받게 되는지를 먼저 정하고, 그 범위를 전략으로 옮겨야 합니다. 저는 mask_email이 @ 없는 값을 받으면 ValueError를 내도록 고쳤고, 그 뒤에야 좁은 전략이 의미를 가졌습니다.

max_examples는 25에서 이미 바닥입니다

max_examples를 올리면 더 잘 찾을 것 같지만, 세 속성 모두 특정 지점에서 탐지율이 100%에 붙고 그 뒤로는 변하지 않았습니다. 시드 1~30, 축소 단계를 뺀 생성 단계만의 측정입니다.

max_examples offset < total 덮임 보존 길이 ≤ limit
5 20.0% 100% 6.7%
10 26.7% 100% 100%
25 100% 100% 100%
50 100% 100% 100%
200 100% 100% 100%

기본값 100은 이 모듈에 대해 네 배쯤 여유가 있는 값이었습니다. 반대로 말하면 기본값으로 못 찾는 결함은 500으로 올려도 대개 못 찾습니다. 그럴 때 올려야 하는 것은 예제 수가 아니라 전략의 폭입니다.

4. CI에 올리는 순간 기본값 다섯 개가 한꺼번에 바뀝니다

프로파일이 통째로 교체됩니다

이것이 이 도구에서 가장 놀랐던 부분입니다. Hypothesis는 CI 환경변수가 어떤 값으로든 설정돼 있으면 default 대신 ci 프로파일을 켭니다. 공식 문서의 settings 레퍼런스에 두 프로파일의 정의가 나란히 적혀 있고, 컨테이너에서 확인한 값도 같았습니다.

설정 로컬(default) CI(ci) 무엇이 달라지는가
derandomize False True CI는 매 실행 같은 입력만 돌립니다
deadline 200ms None 느린 호출이 CI에서만 통과합니다
database 로컬 .hypothesis/ None CI는 실패 사례를 저장하지 않습니다
print_blob False True 실패 시 재현용 blob을 출력합니다
suppress_health_check [] ['too_slow'] 느림 경고가 CI에서만 꺼집니다

derandomize=True가 실무에서 가장 큰 차이입니다. 입력 30개를 파일로 흘려 해시를 재 봤습니다. 환경변수 없이 세 번 돌리면 19d2000e71, b7e0c67d73, b8f4e023d0으로 전부 달랐고, CI=true를 주고 세 번 돌리면 9fb6310819가 세 번 나왔습니다. 매일 밤 CI가 새 입력을 탐색해 줄 것이라는 기대는 성립하지 않습니다. CI는 같은 100개를 반복하고, 새 입력은 개발자 노트북에서만 나옵니다.

Jenkins는 감지되지 않았습니다

Hypothesis는 벤더별 CI 환경변수도 자동으로 봅니다. 어디까지 보는지 직접 확인했습니다.

# 환경변수만 바꿔 가며 활성 프로파일의 기본값을 읽습니다
for env in CI=true GITHUB_ACTIONS=true BUILDKITE=true CIRCLECI=true \
           GITLAB_CI=true JENKINS_URL=http://x; do
  env $env python3 -c "
from hypothesis import settings
d = settings.default
print(f'{d.derandomize}  {d.deadline}  {d.print_blob}')"
done

CI / GITHUB_ACTIONS / BUILDKITE / CIRCLECI / GITLAB_CI는 전부 ci 프로파일로 전환됐습니다. 그런데 JENKINS_URL만 준 경우에는 derandomize=False, deadline=200ms로 로컬 기본값이 그대로 남았습니다. Jenkins를 쓰는 팀은 러너에서 200ms 데드라인이 살아 있고 실패 사례가 워크스페이스에 쌓인다는 뜻입니다. 저희 파이프라인은 GitHub Actions라 해당하지 않았지만, 확인하지 않았다면 원인 모를 DeadlineExceeded를 만났을 자리입니다. 명시적으로 CI=1을 넣어 두는 편이 안전합니다.

실패 사례 저장소와 CI 재현

로컬에서는 .hypothesis/ 디렉터리에 실패 입력이 저장되고, 다음 실행에서 그것을 먼저 시도합니다. 호출 횟수를 세어 봤습니다.

실행 실패까지 호출된 예제 수
1회차 (저장소 비어 있음) 35
2회차 (실패 사례 저장됨) 2
3회차 (저장소 삭제 후) 45

저장소는 파일 33개, 55,120바이트였습니다. CI는 database=None이라 이 이득이 없고, 대신 print_blob=True가 켜져 있어 실패 로그에 재현 수단이 찍힙니다. CI 로그에서 복사해 로컬 테스트에 붙이면 그 입력만 즉시 재현됩니다.

from hypothesis import reproduce_failure

@reproduce_failure('6.168.0', b'AEEBQQFBAg==')   # CI 로그에서 그대로 복사
@given(st.integers(1, 10_000), st.integers(1, 500), st.integers(-5, 10_000))
def test_from_ci_blob(total, per_page, page):
    assert paginate(total, per_page, page)["offset"] < total

1 failed in 0.12s, 실패 케이스는 total=1, per_page=1, page=2로 CI가 잡은 것과 같았습니다. 다만 blob은 버전 문자열을 함께 검사하므로, 로컬과 CI의 Hypothesis 버전이 다르면 쓸 수 없습니다. 임시로만 붙이고 원인을 고친 뒤에는 @example로 바꿔 두는 편이 낫습니다.

5. 첫날 실제로 걸린 함정 세 개

200ms를 넘기면 멈추는 데드라인

한 번 호출에 250ms 걸리는 대상을 그냥 감싸면 이렇게 멈춥니다.

hypothesis.errors.DeadlineExceeded: Test took 260.42ms, which exceeds
the deadline of 200.00ms.

DB나 외부 API를 건드리는 테스트라면 거의 확실히 걸립니다. 그리고 앞서 본 대로 CI에서는 deadline=None이라 통과합니다. 로컬에서만 실패하고 CI는 초록인 상태가 만들어집니다. 저는 느린 대상에는 deadline=None을 명시해 양쪽 동작을 일치시켰습니다.

함수 스코프 픽스처는 예제마다 초기화되지 않습니다

@pytest.fixture로 만든 리스트를 @given 테스트에 주입하고 예제마다 비어 있기를 기대했더니 헬스체크가 막았습니다.

hypothesis.errors.FailedHealthCheck: uses a function-scoped fixture 'bucket'.

픽스처는 테스트 함수 한 번에 한 번 실행되고, 그 안에서 예제 100개가 돕니다. 상태가 예제 사이로 새어 나갑니다. Hypothesis 저장소에도 같은 혼동이 이슈 #2731과 #2733으로 남아 있습니다. suppress_health_check로 끄는 방법이 안내되지만, 끄기 전에 상태가 정말 새어도 되는지 먼저 봐야 합니다. 저는 테스트 본문 안에서 컨텍스트 매니저로 매번 만드는 쪽으로 바꿨습니다.

조건을 걸러 내면 생성이 버려집니다

전략에 .filter()를 붙여 조건을 좁히면 조건을 만족하지 못한 입력이 통째로 버려집니다. "@" in s 하나만 걸었는데도 이렇게 나왔습니다.

FailedHealthCheck: 4 inputs were generated successfully,
while 50 inputs were filtered out.

해법은 거르는 것이 아니라 조립하는 것입니다. st.builds(lambda a, b: a + "@" + b, st.text(max_size=12), st.text(max_size=12))로 바꾸니 경고 없이 통과했습니다. 거름망을 좁히는 대신 원하는 모양을 직접 만들어 주는 쪽이 언제나 빠릅니다.

6. QA 네다섯 명 팀이 이번 주에 어디까지 할 것인가

비용은 예상보다 작았습니다

전부 통과하는 상태에서 5회 최솟값으로 쟀습니다.

구성 실행 시간
예제 12개 0.132초
속성 4개 · max_examples=25 0.171초
속성 4개 · max_examples=100 (기본값) 0.311초
속성 4개 · max_examples=500 0.883초
속성 4개 · max_examples=1000 1.588초

기본값 100에서 속성 하나당 약 0.045초입니다. 순수 함수 기준이라 DB나 네트워크가 끼면 이 비율은 그대로 적용되지 않습니다. 그래도 속성 20개를 붙여도 1.5초 안쪽이라는 뜻이므로, 초기 단계 팀이 비용 때문에 미룰 이유는 크지 않았습니다.

도입 난이도·유지보수 비용·학습곡선

항목 평가 근거
도입 난이도 낮음 pip install hypothesis 하나. pytest 구조를 바꾸지 않고 파일 단위로 추가 가능
유지보수 비용 중간 전략 정의가 곧 입력 명세라 도메인이 바뀌면 같이 고쳐야 함. .hypothesis/는 .gitignore에 넣고 반례는 @example로 코드에 남겨야 함
팀 학습곡선 중간 "값을 쓰지 않고 규칙을 쓴다"는 전환이 첫 장벽. 반례를 규칙으로 승격하는 연습이 두 번째
적용 범위 좁게 시작 순수 함수·변환·파싱·계산에 먼저. 외부 I/O가 섞인 경로는 deadline과 픽스처 문제부터 정리한 뒤

전 스위트를 속성 기반으로 바꿀 이유는 없습니다. 값이 딱 하나로 정해지는 경우는 예제 테스트가 더 읽기 쉽고 더 빠릅니다. 저는 계산·파싱·정규화 함수처럼 입력 공간이 넓고 규칙을 한 문장으로 적을 수 있는 곳에만 붙였고, 그 기준으로 고른 네 함수에서 반례 세 개가 나왔습니다.

한 가지는 먼저 정해 두는 편이 좋습니다. 속성이 예제 테스트와 충돌하면 둘 중 하나는 틀린 것입니다. 이번에는 예제 쪽이 틀렸지만, 반대일 수도 있습니다. 그 판단을 리뷰에서 매번 즉흥적으로 하면 사람마다 다르게 결론이 납니다. 시드와 실행 순서가 결과를 바꾼다는 점은 pytest-randomly의 훅 래퍼 전환을 정리하면서 한 번 확인했는데, 속성 기반 테스트에서는 그 영향이 더 직접적입니다.

붙이기 전에 확인할 것

# 1) 파이프라인이 ci 프로파일로 인식되는지 — 5개 값이 CI 쪽으로 바뀌어야 합니다
python3 -c "
from hypothesis import settings
d = settings.default
print('derandomize', d.derandomize, '| deadline', d.deadline,
      '| database', type(d.database).__name__,
      '| print_blob', d.print_blob,
      '| suppress', [h.name for h in d.suppress_health_check])"

# 2) 실패 사례 저장소가 커밋되지 않도록 막습니다
grep -q '^\.hypothesis' .gitignore || echo '.hypothesis/' >> .gitignore

# 3) filter 남용 여부 — .filter( 가 전략에 붙어 있으면 st.builds 조합을 먼저 검토합니다
grep -rn '\.filter(' tests/ | head

# 4) 잡은 반례가 코드에 남아 있는지 — @given 대비 @example 개수를 봅니다
echo "given: $(grep -rc '@given' tests/ | awk -F: '{s+=$2} END {print s}')"
echo "example: $(grep -rc '@example' tests/ | awk -F: '{s+=$2} END {print s}')"

이 기록의 한계

측정은 Ubuntu 22.04 aarch64 4코어 단일 머신 한 종에서만 했습니다. 대상 모듈은 22 statement짜리 순수 함수 네 개이므로 비용 수치를 그대로 큰 스위트에 곱하면 안 됩니다. 탐지율 표는 제가 만든 네 속성과 시드 30개에 한정된 값이며, 결함의 성질에 따라 필요한 max_examples는 크게 달라집니다. 벤더 CI 감지 목록도 여섯 종만 확인했고 전수가 아닙니다. xdist -n 4에서 속성 4개가 1.02초(직렬 0.37초)로 오히려 느렸는데, 대상이 작아 워커 기동 비용이 지배한 것으로 보이며 큰 스위트에서 같은 방향일 것이라는 근거는 없습니다.

댓글

이 블로그의 인기 게시물

옵시디언 3기기 동기화, 유료 결제 없이 구글 드라이브로 끝내기

최저가 주유소, 얼마나 돌아가면 손해일까?

회의시간 줄이는 법, 개수보다 배치 먼저

유류세 인하 2개월 연장, 주유소 가격 실제 인하폭 계산

국민연금 추납 조회, 미루면 얼마나 더 낼까?