본문으로 건너뛰기
yutils

property-based testing 은 어떻게 동작할까?

example-based vs property-based, generator + shrinker (왜 실패 input 이 [42,17,88] 이 아닌 [] 가 되는지), QuickCheck / Hypothesis / fast-check, property test 만 잡는 버그.

약 9분 읽기

전통 test = "이 입력에 이 출력". 그런데 100 example 도 안 잡는 edge case 가 production 에서. Property-based testing 은 generator 가 수천 input 자동 생성 + 입력 → 입력의 invariant (속성) 만 검증. 이 가이드는 generator / shrinker / QuickCheck 계열의 메커니즘과, "이것만 잡는 버그" 의 type 을 정리한다.

Example-based vs Property-based

Example-based (전통):
test("reverse twice = original", () => {
  expect(reverse(reverse([1,2,3]))).toEqual([1,2,3]);
  expect(reverse(reverse([]))).toEqual([]);
  expect(reverse(reverse([7]))).toEqual([7]);
  // ... 5 example 추가
});

→ 5 example 만 검증. [1, NaN, undefined, [], "x"] 같은 edge case?

Property-based:
test("reverse twice = original", () => {
  fc.assert(
    fc.property(fc.array(fc.anything()), (arr) => {
      expect(reverse(reverse(arr))).toEqual(arr);
    })
  );
});

→ fc.array(fc.anything()) generator 가 1000+ array 자동 생성.
  string, number, null, undefined, NaN, nested array, ... 모두 시도.
  속성 깨지는 첫 case 발견 시 fail.

Property — 무엇을 검증하나

"입력 → 출력" 의 universal 한 관계.

흔한 property 유형:

1. Roundtrip (역연산):
   parse(serialize(x)) === x
   decode(encode(x)) === x

2. Idempotence (반복 적용):
   normalize(normalize(x)) === normalize(x)
   sort(sort(x)) === sort(x)

3. Invariant (불변):
   sort(arr).length === arr.length
   max(arr) >= min(arr)

4. Equivalence (다른 구현 비교):
   slowButCorrect(x) === fastNewImpl(x)
   nativeRegex(x) === customRegex(x)

5. Algebraic laws:
   (a + b) + c === a + (b + c)   // associativity
   merge(a, b) === merge(b, a)    // commutativity

Generator — 입력 자동 생성

fast-check (TypeScript) 예:

fc.integer()              → -2147483648..2147483647
fc.integer({min: 0, max: 100})  → 0..100
fc.string()               → 임의 문자열 (empty 포함)
fc.string({minLength: 5}) → 5+ 글자
fc.array(fc.integer())    → 임의 길이의 int array
fc.record({               → 객체
  id: fc.integer(),
  name: fc.string(),
  active: fc.boolean()
})
fc.oneof(fc.integer(), fc.string())  → int 또는 string

→ "어떤 input 도 가능" 의 의도를 명시.
   edge case (empty array, max int, "" string, NaN, ...) 자동 포함.

Shrinker — 가장 작은 실패 input

fail 발견 시 generator 가 같은 fail 일으키는 더 작은 input 시도.

처음 fail: arr = [42, 17, 88, -3, 1024]  → reverse(reverse) 결과 깨짐
shrink 1: arr = [42, 17, 88, -3]         → 여전히 fail?
shrink 2: arr = [42, 17, 88]             → 여전히 fail?
shrink 3: arr = [42, 17]                 → fail!
shrink 4: arr = [42]                     → pass (1 element 는 OK)
shrink 5: arr = [17]                     → pass
shrink 6: arr = [0, 0]                   → fail!
shrink 7: arr = []                       → pass

→ minimal failing input: [0, 0]
   "두 element 가 같으면 깨진다" 같은 패턴 발견 쉬움

→ debug 의 가치 큼. random [42, 17, 88, -3, 1024] 보다 [0, 0] 이
   훨씬 원인 파악 빠름.

이 방식만 잡는 버그 type

1. Edge case (empty, 0, max, NaN)

function discount(items) {
  return items.reduce((sum, i) => sum + i.price, 0) * 0.9;
}

example-based: [{price:100}, {price:200}] → 270  ✓

property-based 발견:
  items = []                  → result = 0 * 0.9 = 0  (예상 동작?)
  items = [{price: NaN}]      → result = NaN          ← bug!
  items = [{price: Infinity}] → result = Infinity     ← edge

2. Roundtrip 실패

function escapeQuotes(s) { return s.replace(/'/g, "\\'"); }
function unescapeQuotes(s) { return s.replace(/\\'/g, "'"); }

property: unescape(escape(s)) === s

발견: s = "\\'"  → escape: "\\\\'"  → unescape: "\\'"  ✗
       (backslash 이미 있는 경우 처리 안 됨)

3. Concurrent / state machine 버그

fast-check / Hypothesis 의 state machine testing:
임의 method 호출 sequence 생성 → invariant 검증.

예: cache.set(k, v) + cache.get(k) + cache.evict(k) 임의 순서
property: get(k) === set 한 마지막 v (또는 evict 후 undefined)

→ 손으로는 시도 안 했을 sequence (set k1, evict k2, get k1) 자동 시도.

라이브러리

언어라이브러리비고
Haskell (원조)QuickCheck (1999)property-based 의 시작
PythonHypothesis가장 mature, stateful 강함
JavaScript / TypeScriptfast-check가장 active, Jest/Vitest 통합
Rustproptest, quickcheckproptest 가 modern 표준
Java / Kotlinjqwik, Kotest property
ScalaScalaCheckHaskell QuickCheck port
Gotesting/quick (stdlib), gopterstdlib 의 quick 은 기본만

흔한 함정

  • 모든 test 를 property 로 — 명확한 example 이 더 읽기 쉬운 경우 많음 (regression test 등). 보완 관계.
  • property 가 너무 약함 — `result !== null` 같은 trivial property. roundtrip / invariant / equivalence 가 가치 있는 property.
  • generator 가 너무 제약 — 의도한 edge case 못 만남. fc.anything() 또는 wide range 시도.
  • test 시간 폭증 — default 100 run × test 50 개 = CI 느려짐. 중요 path 만 property, 나머지 example.
  • seed 안 박음 — 다른 사람이 같은 fail 재현 못함. fail 시 seed 출력 + 그 seed 박는 test 추가 (regression).

마무리

Property-based testing 의 본질 — "수많은 example 을 손으로 만드는 대신 input 의 universal property 만 명시". generator + shrinker 가 edge case 자동 발견 + minimal repro 자동 생성.

실용 — 모든 test 를 property 로 안 함. parser / serializer / algorithm 의 핵심 함수에 property test 추가, 나머지는 example-based. QuickCheck / Hypothesis / fast-check 의 stateful machine 검증도 활용 가치 큼.

가이드 목록으로