프로퍼티 기반 테스트의 계보 — QuickCheck에서 Hypothesis까지

2000년, 클라선과 휴즈가 Haskell용 QuickCheck를 발표하며 「구체적 예시가 아니라 성질을 쓴다」는 새로운 테스트 발상을 열었다. 제너레이터와 슈링킹, Hypothesis·fast-check·proptest로의 확산, 그리고 퍼징과의 관계까지 살펴본다.

testinghistoryproperty-based-testingquickcheckfuzzinghaskell

지난번에 살펴본 모의 객체(제6기사)는 「특정 호출이 올바르게 이루어졌는가」라는 구체적인 행동을 검증하는 기법이었다. 대부분의 단위 테스트도 마찬가지로, 「이 입력에 대해 이 출력이 나온다」는 구체적 예시(example) 를 하나씩 작성한다. 그러나 코드가 만족해야 할 성질——「정렬 후에도 리스트의 길이는 변하지 않는다」, 「직렬화했다가 역직렬화하면 원래대로 돌아온다」——을 먼저 정의하고, 그 성질을 만족하는지를 도구가 대량의 무작위 입력으로 검증하게 하는, 전혀 다른 접근법이 있다. 프로퍼티 기반 테스트(property-based testing, PBT) 다.

QuickCheck 논문 — 2000년, ICFP

2000년, 스웨덴 예테보리의 찰머스 공과대학교에서 코엔 클라선(Koen Claessen)과 존 휴즈(John Hughes)는 ACM SIGPLAN International Conference on Functional Programming(ICFP 2000) 에서 “QuickCheck: A Lightweight Tool for Random Testing of Haskell Programs” 라는 논문을 발표했다. 이 논문이 프로퍼티 기반 테스트라는 개념을 세상에 널리 알린 출발점이다.

논문에서 제시된 QuickCheck의 구현은 처음에는 겨우 300줄 정도의 단순한 라이브러리였지만, 그 아이디어는 강력했다.

-- "리스트를 뒤집고 다시 뒤집으면 원래대로 돌아온다"는 성질
prop_reverseReverse :: [Int] -> Bool
prop_reverseReverse xs = reverse (reverse xs) == xs

프로그래머는 개별적인 입출력 예시가 아니라 만족되어야 할 성질(property) 을 함수로 작성한다. QuickCheck는 그 성질에 대해 타입 정보를 바탕으로 무작위 입력을 대량으로 생성하고, 반례가 발견될 때까지 테스트를 반복한다.

제너레이터와 슈링킹

QuickCheck가 확립한 두 가지 핵심 개념이 프로퍼티 기반 테스트를 실용적인 기법으로 작동하게 한다.

제너레이터(generator) 는 테스트 대상 타입(정수·문자열·리스트·사용자 정의 데이터 타입)에 따라 무작위 값을 생성하는 장치다. QuickCheck는 타입 클래스(Arbitrary)를 통해 표준 타입에는 자동으로 제너레이터를 제공하고, 사용자 정의 타입에는 사용자가 직접 정의할 수 있도록 했다.

슈링킹(shrinking, 축소) 은 반례가 발견되었을 때 그 입력을 더 단순한 형태로 자동으로 축소해 가는 장치다. 원소 47개짜리 리스트에서 실패가 재현되더라도, 그대로는 사람이 읽기 어렵다. QuickCheck는 성질이 깨진 채로 입력을 계속 축소해, 최종적으로 「이 이상 단순화할 수 없는 최소 반례」를 제시한다. 이 「제너레이터 + 슈링킹」의 조합이야말로 QuickCheck 계열 도구들이 공유하는 아키텍처다.

생성된 실패 입력: [3, -17, 0, 256, -1, 42]
                   ↓ shrink
                [0, -1]
                   ↓ shrink
                [-1]          ← 더 이상 작게 만들 수 없는 최소 반례

Quviq QuickCheck — 상용화와 동시성 시스템으로의 응용

존 휴즈는 2006년, QuickCheck의 기술을 상용화하기 위해 Quviq사를 설립하고, Erlang용 상용판 QuickCheck를 제공했다. 동시성·분산 시스템의 복잡한 버그(경쟁 상태, 노드 장애 시의 불일치 등)를 검출하는 용도로 통신·자동차 업계의 실제 시스템에 적용되었으며, 실제로 심각한 버그를 다수 발견한 실적으로 알려져 있다.

Erlang판은 상태를 가진 시스템(프로토콜 구현이나 데이터베이스 등)을 「상태 기계로 모델화하고, 무작위로 생성한 조작 시퀀스를 모델과 실제 시스템 양쪽에 적용해 정합성을 검증한다」는 상태 기반 프로퍼티 테스트(stateful property testing) 기법을 발전시킨 점에서도 중요하다. 무작위로 생성된 「조작의 순서」 자체가 버그를 유발하는 경우가 많아, 단발성 입력 테스트로는 발견할 수 없는 경쟁 상태나 타이밍 의존 버그를 발견할 수 있다는 특징이 있다.

각 언어로의 확산

QuickCheck의 아이디어는 Haskell 밖으로 급속히 퍼져나가, 오늘날에는 거의 모든 주요 언어에 「QuickCheck 계열」 라이브러리가 존재한다.

언어 라이브러리 비고
Haskell QuickCheck 원조(Claessen & Hughes, 2000)
Erlang Quviq QuickCheck 상용, 동시성·분산 시스템용
Scala ScalaCheck 리카드 닐손(Rickard Nilsson)이 개발
Python Hypothesis 데이비드 R. 매카이버(David R. MacIver)가 개발
JavaScript/TypeScript fast-check Jest 등과 함께 사용됨
Rust proptest / quickcheck proptest는 Hypothesis 계열의 설계 사상을 채택
Java jqwik JUnit 5와의 통합을 중시
C#/F# FsCheck .NET용

Hypothesis의 독자성

Python의 Hypothesis는 단순히 QuickCheck를 이식한 것이 아니라, 몇 가지 중요한 진화를 이루었다. 「생성된 값의 이력을 데이터베이스에 저장해, 다음 테스트 실행부터는 과거에 발견된 반례를 우선적으로 재현한다」는 통합적인 장치와, 슈링킹의 정교함이 특징이다.

from hypothesis import given, strategies as st

@given(st.lists(st.integers()))
def test_reverse_twice_is_identity(xs):
    assert list(reversed(list(reversed(xs)))) == xs

이 테스트는 하나의 입력 예시가 아니라 「임의의 정수 리스트」 전체에 대해 실행되며, 실패하는 입력이 발견되면 슈링킹을 거쳐 최소 반례가 보고된다. Rust의 proptest는 Hypothesis에서 영감을 받았음을 명시하고 있으며, 「전략(strategy)」이라는 어휘와 통합 슈링킹 설계를 계승하고 있다. JavaScript의 fast-check도 같은 사상으로 작성된다.

import fc from "fast-check";

fc.assert(
  fc.property(fc.array(fc.integer()), (xs) => {
    const roundTripped = JSON.parse(JSON.stringify(xs));
    return JSON.stringify(roundTripped) === JSON.stringify(xs);
  })
);

두 예시 모두, 테스트 코드 안에 「구체적인 입력값」이 전혀 등장하지 않는다는 점이 일반적인 단위 테스트와의 가장 큰 차이다.

슈링킹의 두 가지 방식

슈링킹의 구현 방식에는 크게 두 가지 유파가 있다.

  • 타입 기반 슈링킹(type-based shrinking): 원조 QuickCheck가 채택한 방식으로, 타입마다 「어떻게 축소할 것인가」를 정의한다(정수라면 0에 가깝게, 리스트라면 원소를 줄이는 식). 구현은 단순하지만, 타입마다 축소 규칙을 개별적으로 작성해야 한다.
  • 통합 슈링킹(integrated shrinking): Hypothesis와 Rust의 proptest가 채택한 방식으로, 값의 생성 과정(난수열의 소비 이력) 자체를 축소의 대상으로 삼는다. 제너레이터의 조합이 복잡하더라도, 생성 로직을 바꾸지 않고 일관된 축소가 가능하다는 장점이 있다.

이 설계의 차이는 복잡한 사용자 정의 데이터 타입을 다룰 때의 사용 편의성에 직결되므로, 라이브러리를 선택할 때 실무적인 판단 근거가 된다.

프로퍼티의 유형 — 무엇을 써야 하는가

프로퍼티 기반 테스트에서 가장 어려운 것은 도구 사용법이 아니라 「어떤 성질을 써야 하는가」를 가려내는 일이다. 실무에서 자주 쓰이는 유형에는 다음이 있다.

  • 라운드트립(round-trip): decode(encode(x)) == x처럼, 변환했다가 되돌리면 원래대로 돌아옴을 검증한다. 직렬화·파서·인코딩 검증에서 자주 등장한다.
  • 불변조건(invariant): 「정렬 후에도 원소 수는 변하지 않는다」, 「스택에 push했다가 pop하면 같은 값이 반환된다」처럼, 조작 전후로 유지되어야 할 성질.
  • 모델 기반(model-based): 단순하지만 느린/명백히 올바른 「모델 구현」을 준비하고, 실제 구현과 출력을 대조한다. 종종 차분 테스트(differential testing) 라고도 불린다.
  • 오라클 문제와의 관계: 「올바른 출력이 무엇인가」를 판정할 기준(테스트 오라클)을 준비할 수 없는 경우에도, 「입력과 출력의 관계가 만족해야 할 성질」이라면 정의할 수 있는 경우가 많다. 프로퍼티 기반 테스트는 엄밀한 오라클 없이도 검증을 가능케 하는 기법으로 자리매김한다.

퍼징과의 합류

자동 생성한 입력으로 프로그램을 검증한다는 발상은 퍼징(fuzzing) 과도 깊이 관련되어 있다. PBT가 「의미 있는 성질이 성립하는가」라는 논리적 정확성을 검증하는 데 반해, 퍼징은 주로 「크래시가 발생하지 않는가」, 「메모리 안전성 위반이 일어나지 않는가」라는 견고성을 검증한다. 2010년대 이후 주류가 된 커버리지 유도 퍼징(coverage-guided fuzzing) 은 실행 시 어떤 코드 경로를 통과했는지 계측하여, 새로운 코드 경로를 통과한 입력을 우선적으로 변이시킴으로써 무작정 생성하는 것보다 훨씬 효율적으로 탐색한다.

도구 특징
AFL(American Fuzzy Lop) Michal Zalewski가 개발해 2013년 공개. coverage-guided fuzzing을 실무에 널리 퍼뜨린 선구적 도구
libFuzzer LLVM 프로젝트에 내장된 인프로세스형 퍼징 엔진
OSS-Fuzz 구글이 2016년 12월에 발표한, 오픈소스 프로젝트용 지속적 퍼징 서비스
go-fuzz / go test -fuzz Dmitry Vyukov가 개발. Go 1.18(2022년)에서 언어 표준 툴체인에 네이티브로 통합되었다
// Go 1.18 이후의 네이티브 퍼징 예시
func FuzzParseURL(f *testing.F) {
    f.Add("https://example.com/path")
    f.Fuzz(func(t *testing.T, input string) {
        u, err := url.Parse(input)
        if err != nil {
            return // 파싱 실패는 허용
        }
        if _, err := url.Parse(u.String()); err != nil {
            t.Errorf("re-parse failed for %q: %v", u.String(), err)
        }
    })
}

둘은 배타적이지 않으며, 최근에는 「구조화된 입력을 생성하면서 커버리지 피드백도 사용하는」 구조 인식형 퍼징도 확산되어, 그 경계는 점차 모호해지고 있다.

실무에서의 채택 현황

프로퍼티 기반 테스트는 강력하지만, 실무에 파고든 정도는 xUnit 계열 단위 테스트만큼 높지 않다. 「성질」을 정식화하는 것 자체가 설계 능력을 요구하며 구체적 예시를 쓰는 것보다 문턱이 높다는 점, 실행 시간이 길어지기 쉬워 CI 환경에서 시드 고정·재현성 관리가 필요하다는 점이 이유로 꼽힌다. 한편, 파서·직렬화기·암호 관련 코드·동시성 등 불변조건이 명확한 도메인 로직에서는 높은 효과를 발휘한다는 점이 널리 인정받고 있으며, Hypothesis의 보급 이후 Python 생태계를 중심으로 채택이 확대되고 있다.

이 기사에서 다음 기사로

프로퍼티 기반 테스트와 퍼징은 「입력 쪽」을 자동 생성함으로써 테스트의 포괄성을 높이는 기법이었다. 다음으로 살펴볼 것은 그 관심을 시스템의 「바깥」——실제로 브라우저를 조작하는 사용자의 시점——으로 옮긴 영역이다. 2004년의 Selenium에서 시작해 Puppeteer, Cypress, Playwright로 이어지는 E2E 테스트의 세대교체를 따라간다.

참고문헌

  • Koen Claessen, John Hughes, “QuickCheck: A Lightweight Tool for Random Testing of Haskell Programs”(ICFP 2000)
  • Koen Claessen, John Hughes, “Testing Monadic Code with QuickCheck”(2002년)
  • Michal Zalewski, american fuzzy lop(2013년 공개)
  • Google, “Announcing OSS-Fuzz: Continuous Fuzzing for Open Source Software”(Google Testing Blog, 2016년 12월)
  • The Go Programming Language, “Go Fuzzing”(go.dev)
  • Hypothesis 문서(hypothesis.works)
← Back to 소프트웨어 테스트의 계보