test.check 입문: 프로퍼티 기반 테스팅 시작하기

test.check 입문: 프로퍼티 기반 테스팅 시작하기

Clojure로 코드를 짜다 보면 "이 함수에 어떤 입력이 들어와도 안전할까?"라는 걱정이 자주 생겨요. 그럴 때 유용한 도구가 바로 test.check라는 프로퍼티 기반 테스팅 라이브러리예요. 이 문서에서는 test.check가 무엇인지, 그리고 기본 API를 어떻게 쓰는지 하나씩 살펴볼게요.

출처: Clojure 공식문서

본문

소개

test.check는 Clojure용 프로퍼티 기반 테스팅 라이브러리로, Haskell의 QuickCheck에서 영감을 받았어요. 이 가이드는 버전 0.10.0을 기준으로, test.check 예시를 통해 프로퍼티 기반 테스팅을 간단히 소개하고 API의 각 부분을 기본적인 수준에서 다룹니다.

프로퍼티 기반 테스팅이란

프로퍼티 기반 테스트는 흔히 "예제 기반 테스트"와 대비돼요. 예제 기반 테스트는 특정 입력과 그에 기대되는 출력을 나열하는 방식, 즉 "예시"를 몇 개 넣어 함수를 검증하는 방식이죠. 이 가이드는 순수 함수를 테스트하는 기준으로 쓰였지만, 순수하지 않은 시스템을 테스트할 때도 테스트를 감싸는 함수를 상상하면 돼요. 그 함수는 인자를 사용해 시스템의 컨텍스트를 준비하고, 시스템을 실행하고, 환경을 조회해 그 효과를 측정한 뒤 그 조회 결과를 반환하는 형태예요.

반면 프로퍼티 기반 테스팅은 **모든 유효한 입력에 대해 참이어야 하는 성질(property)**을 서술해요. 프로퍼티 기반 테스트는 유효한 입력을 만드는 방법(제너레이터, generator)과, 생성된 입력을 받아 테스트 대상 함수와 결합해 그 입력에 대해 성질이 성립하는지 판단하는 함수로 구성돼요.

프로퍼티의 가장 대표적인 첫 예시는 sort 함수를 **멱등성(idempotent)**으로 검증하는 것이에요. test.check에서는 이렇게 쓸 수 있어요:

(require '[clojure.test.check :as tc])
(require '[clojure.test.check.generators :as gen])
(require '[clojure.test.check.properties :as prop])

(def sort-idempotent-prop
  (prop/for-all [v (gen/vector gen/int)]
    (= (sort v) (sort (sort v)))))

(tc/quick-check 100 sort-idempotent-prop)
;; => {:result true,
;; =>  :pass? true,
;; =>  :num-tests 100,
;; =>  :time-elapsed-ms 28,
;; =>  :seed 1528580707376}

여기서 (gen/vector gen/int) 표현식이 sort 함수의 입력을 만드는 제너레이터예요. 즉 "입력은 정수들의 벡터"임을 지정하죠. 실제로 sort는 서로 Comparable한 객체들의 어떤 컬렉션이든 받을 수 있는데, 제너레이터의 단순함과 그것이 실제 입력 공간을 얼마나 완전하게 표현하는지 사이에는 종종 트레이드오프가 있어요.

이름 v는 생성된 특정 정수 벡터에 결합되고, prop/for-all 바디의 표현식이 그 시행이 통과했는지 실패했는지를 결정해요.

tc/quick-check 호출은 프로퍼티를 100번 "실행"해요. 즉 정수 벡터 100개를 생성하고 각각에 대해 (= (sort v) (sort (sort v)))를 평가하죠. 각 시행이 모두 통과해야만 성공으로 보고해요.

어떤 시행 하나라도 실패하면 test.check는 입력을 "축소(shrink)"해서 최소 실패 예시를 만들어내고, 원래 실패 예시와 축소된 예시 모두를 보고해요. 예를 들어 이 잘못된 프로퍼티는 정수 벡터를 정렬한 뒤 첫 번째 요소가 마지막 요소보다 작아야 한다고 주장해요:

(def prop-sorted-first-less-than-last
  (prop/for-all [v (gen/not-empty (gen/vector gen/int))]
    (let [s (sort v)]
      (< (first s) (last s)))))

이 프로퍼티를 tc/quick-check로 실행하면 이런 결과가 나와요:

{:num-tests 5,
 :seed 1528580863556,
 :fail [[-3]],
 :failed-after-ms 1,
 :result false,
 :result-data nil,
 :failing-size 4,
 :pass? false,
 :shrunk
 {:total-nodes-visited 5,
  :depth 2,
  :pass? false,
  :result false,
  :result-data nil,
  :time-shrinking-ms 1,
  :smallest [[0]]}}

원래 실패 예시 [-3](:fail 키에 표시)가 [0]([:shrunk :smallest] 아래)으로 축소됐고, 그 외에도 다양한 데이터가 함께 제공돼요.

제너레이터

test.check의 각 부분은 네임스페이스로 깔끔하게 분리돼 있어요. 아래에서 위로, 즉 제너레이터부터 시작해서 프로퍼티, 그리고 테스트를 실행하는 두 가지 방법 순서로 살펴볼게요.

제너레이터는 clojure.test.check.generators 네임스페이스가 담당해요. 내장 제너레이터는 세 가지 범주로 나뉘지요: 스칼라(기본 데이터 타입), 컬렉션, 콤비네이터. 콤비네이터는 임의의 사용자 정의 타입을 위한 제너레이터를 만드는 데 쓸 만큼 일반적이에요.

개발 도구

제너레이터를 실험해 보기 위한 개발용 함수가 몇 가지 있어요. 먼저 이걸 소개할게요. 나머지 제너레이터 기능을 보여줄 때 쓸 수 있거든요.

gen/sample 함수는 제너레이터를 받아 그 제너레이터에서 나온 작은 샘플 요소들의 컬렉션을 반환해요:

user=> (gen/sample gen/boolean)
(true false true true true false true true false false)

gen/generate 함수는 제너레이터를 받아 요소 하나를 생성하고, 추가로 요소의 size를 지정할 수 있어요. size는 추상적인 파라미터로, 일반적으로 0에서 200까지의 정수예요.

user=> (gen/generate gen/large-integer 50)
-165175

스칼라 제너레이터

test.check는 부울, 숫자, 문자, 문자열, 키워드, 심볼, UUID를 위한 제너레이터를 제공해요. 예를 들면:

user=> (gen/sample gen/double)
(-0.5 ##Inf -2.0 -2.0 0.5 -3.875 -0.5625 -1.75 5.0 -2.0)

user=> (gen/sample gen/char-alphanumeric)
(\G \w \i \1 \V \U \8 \U \t \M)

user=> (gen/sample gen/string-alphanumeric)
("" "" "e" "Fh" "w46H" "z" "Y" "7" "NF4e" "b0")

user=> (gen/sample gen/keyword)
(:. :Lx :x :W :DR :*- :j :g :G :_)

user=> (gen/sample gen/symbol)
(+ kI G uw jw M9E ?23 T3 * .q)

user=> (gen/sample gen/uuid)
(#uuid "c4342745-9f71-42cb-b89e-e99651b9dd5f"
 #uuid "819c3d12-b45a-4373-a307-5943cf17d90b"
 #uuid "c72b5d34-255f-408f-8d16-4828ed740904"
 #uuid "d342d515-b297-4ed4-91cc-8cd55007e2c2"
 #uuid "6d09c6f3-12d4-4e5e-9de5-0ed32c9fef20"
 #uuid "a572178c-5460-44ee-b992-9d3d26daf8c0"
 #uuid "572cc48e-b3a8-40ca-9449-48af08c617d3"
 #uuid "5f6ed50b-adef-4e7f-90d0-44511900491e"
 #uuid "ddbbfd07-d580-4638-9858-57a469d91727"
 #uuid "c32b7788-70de-4bf5-b24f-1e7cb564a37d")

컬렉션 제너레이터

컬렉션 제너레이터는 보통 요소용 제너레이터를 인자로 받는 함수예요. 예를 들어:

user=> (gen/generate (gen/vector gen/boolean) 5)
[false false false false]

참고로 여기서 gen/generate의 두 번째 인자는 컬렉션의 크기를 지정하는 게 아니라, 앞서 언급한 추상적인 size 파라미터를 지정하는 거예요. gen/generate의 기본값은 30이에요.

이기종 컬렉션을 위한 제너레이터도 있는데, 그중 가장 중요한 건 gen/tuple이에요:

user=> (gen/generate (gen/tuple gen/boolean gen/keyword gen/large-integer))
[true :r -85718]

일부 컬렉션 제너레이터는 더 커스터마이즈할 수도 있어요:

user=> (gen/generate (gen/vector-distinct (gen/vector gen/boolean 3)
                                          {:min-elements 3 :max-elements 5}))
[[true  false false]
 [true  true  false]
 [false false true]
 [false true  true]]

제너레이터 콤비네이터

스칼라·컬렉션 제너레이터는 다양한 구조를 만들 수 있지만, 수준 있는 커스텀 제너레이터를 만들려면 콤비네이터를 써야 해요.

gen/one-of 는 제너레이터들의 컬렉션을 받아 그 중 어떤 값이든 생성할 수 있는 제너레이터를 반환해요:

user=> (gen/sample (gen/one-of [gen/boolean gen/double gen/large-integer]))
(-1.0 -1 true false 3 true true -24 -0.4296875 3)

gen/frequency도 있는데, 비슷하지만 각 제너레이터에 가중치(weight)를 지정할 수 있어요.

gen/such-that 는 기존 제너레이터를 프레디킷을 사용해 그 값들의 부분집합으로 제한해요:

user=> (gen/sample (gen/such-that odd? gen/large-integer))
(3 -1 -1 -1 -3 5 -11 1 -1 -5)

다만 여기엔 마법이 없어요. 프레디킷을 만족하는 값을 생성하는 유일한 방법은 값이 우연히 맞을 때까지 계속 생성하는 것뿐이에요. 즉 gen/such-that는 프레디킷이 여러 번 연속으로 맞지 않으면 임의로 실패할 수 있어요:

user=> (count (gen/sample (gen/such-that odd? gen/large-integer) 10000))
ExceptionInfo Couldn't satisfy such-that predicate after 10 tries.  clojure.core/ex-info (core.clj:4754)

gen/sample 호출(홀수 10000개를 요청한 것)은 실패해요. gen/large-integer가 대략 절반 정도는 짝수를 반환하니, 짝수가 10번 연속 나오는 일이 그렇게 드물지 않거든요.

gen/such-that는 프레디킷이 성공할 가능성이 매우 높을 때만 쓰는 게 좋아요. 그 외 경우엔, 아래 gen/fmap에서 보듯 제너레이터를 만드는 다른 방법이 있는 경우가 많아요.

gen/fmap 는 제너레이터가 생성하는 값을 수정하는 함수를 제공해 어떤 제너레이터든 수정할 수 있게 해줘요. 이걸 사용해 필요한 조각들을 생성한 뒤 gen/fmap 함수에서 결합함으로써 임의의 구조나 커스텀 객체를 만들 수 있어요:

user=> (gen/generate (gen/fmap (fn [[name age]]
                                 {:type :humanoid
                                  :name name
                                  :age  age})
                               (gen/tuple gen/string-ascii
                                          (gen/large-integer* {:min 0}))))
{:type :humanoid, :name ".o]=w2hZ", :age 14}

gen/fmap의 또 다른 용도는 목표 지향적 변환을 사용해 다른 제너레이터의 분포를 제한하거나 왜곡하는 거예요. 예를 들어 일반 정수 제너레이터를 홀수 제너레이터로 바꾸려면, #(+ 1 (* 2 %)) 함수를 쓰는 gen/fmap을 쓸 수도 있어요(이건 분포의 범위도 두 배로 만드는 효과가 있어요). 아니면 #(cond-> % (even? %) (+ 1))을 쓸 수도 있어요(이건 범위를 늘리지 않죠).

다음은 대문자 문자열만 생성하는 제너레이터예요:

user=> (gen/sample (gen/fmap #(.toUpperCase %) gen/string-ascii))
("" "" "JT" "" ">Y1@" "" "]-" "XCJ@C" "<ANF.\"|" "I@O\"M")

gen/bind 는 가장 고급 콤비네이터로, 여러 단계에 걸쳐 생성할 수 있어요. 이후 단계의 제너레이터는 이전 단계에서 생성된 값을 사용해 구성하죠.

복잡해 보일 수 있지만 시그니처는 gen/fmap과 크게 다르지 않아요. 인자 순서가 뒤바뀌었고, 함수가 값을 반환하는 대신 제너레이터를 반환할 것으로 기대한다는 점만 달라요.

예를 들어 숫자들의 랜덤 목록을 두 가지 다른 순서로 생성하고 싶다고 해볼게요(예: 컬렉션 순서에 무관한 함수를 테스트하려는 경우). 이건 gen/fmap이나 다른 콤비네이터로 하기 어려워요. 두 컬렉션을 직접 생성하면 보통 서로 다른 요소를 가진 컬렉션이 나오고, 하나만 생성하면 그 목록을 (예컨대 gen/shuffle 같은) 재정렬할 수 있는 다른 제너레이터와 함께 쓸 기회가 없거든요.

gen/bind는 우리가 필요한 정확히 두 단계 구조를 제공해요:

user=> (gen/generate (gen/bind (gen/vector gen/large-integer)
                               (fn [xs]
                                 (gen/fmap (fn [ys] [xs ys])
                                           (gen/shuffle xs)))))
[[-5967 -9114 -2 -4 68583042 223266 540 3 -100]
 [223266 -9114 -2 -100 3 540 -5967 -4 68583042]]

여기 구조는 좀 난해한데, gen/bind에 넘긴 함수가 그냥 (gen/shuffle xs)를 호출할 수 없기 때문이에요. 그렇게 하면 전체 제너레이터가 (gen/shuffle xs)로 생성한 하나의 컬렉션만 반환해 버려요. gen/shuffle로 두 번째 컬렉션을 생성하면서도 원래 컬렉션도 반환하기 위해, gen/fmap으로 둘을 벡터로 합치는 거예요.

셔플을 한 번 더 하는 대신 구조가 좀 더 단순한 다른 방법도 있어요:

user=> (gen/generate (gen/bind (gen/vector gen/large-integer)
                               (fn [xs] (gen/vector (gen/shuffle xs) 2))))
[[-4 254202577 -27512 1596863 0 6] [-4 6 254202577 1596863 -27512 0]]

다만 가독성이 더 좋은 옵션은 gen/let 매크로를 쓰는 거예요. 이 매크로는 let과 유사한 문법으로 gen/fmapgen/bind의 용도를 서술해요:

user=> (gen/generate
        (gen/let [xs (gen/vector gen/large-integer)
                  ys (gen/shuffle xs)]
          [xs ys]))
[[0 47] [0 47]]

프로퍼티

프로퍼티는 실제 테스트예요. 테스트하려는 함수와 제너레이터를 결합해, 생성된 값들에 대해 함수가 기대대로 동작하는지 확인하죠.

프로퍼티는 clojure.test.check.properties/for-all 매크로로 만들어요. 첫 번째 예시의 프로퍼티는 벡터를 생성한 뒤 테스트 대상 함수(sort)를 세 번 호출하죠.

프로퍼티는 여러 제너레이터를 결합할 수도 있어요. 예를 들어:

(def +-is-commutative
  (prop/for-all [a gen/large-integer
                 b gen/large-integer]
    (= (+ a b) (+ b a))))

프로퍼티를 실제로 실행하는 방법은 두 가지가 있는데, 다음 두 절에서 다룰게요.

quick-check

테스트를 실행하는 독립적·함수적 방법은 clojure.test.check 네임스페이스의 quick-check 함수예요. 프로퍼티와 시행 횟수를 받아 그 횟수만큼 프로퍼티를 실행하고, 성공 또는 실패를 설명하는 맵을 반환해요. 예시는 위에서 봤죠.

defspec

defspecclojure.test가 인식하고 실행하는 프로퍼티 기반 테스트를 작성하는 매크로예요. quick-check와의 차이는 일부는 문법적이고, 일부는 테스트를 실행하는 대신 정의한다는 점이에요.

예를 들어, 이 가이드의 첫 번째 quick-check 예시는 이렇게도 쓸 수 있어요:

(require '[clojure.test.check.clojure-test :refer [defspec]])

(defspec sort-is-idempotent 100
  (prop/for-all [v (gen/vector gen/int)]
    (= (sort v) (sort (sort v)))))

이렇게 정의하면 같은 네임스페이스에서 (clojure.test/run-tests)를 호출했을 때 이런 출력이 나와요:

Testing my.test.ns
{:result true, :num-tests 100, :seed 1536503193939, :test-var "sort-is-idempotent"}

Ran 1 tests containing 1 assertions.
0 failures, 0 errors.

더 알아보기

더 자세한 문서는 test.check README를 참고하세요.