Clojure spec 가이드: 데이터 구조를 정의하고 검증하고 생성하기

Clojure spec 가이드: 데이터 구조를 정의하고 검증하고 생성하기

Clojure 프로그램은 데이터를 다루는 일이 많아요. 함수를 만들다 보면 "이 함수에 들어오는 데이터가 정말 이렇게 생겼을까?"라는 걱정이 생기기 마련이죠. spec 라이브러리는 바로 그 고민을 해결해 줘요. 데이터의 구조를 정의하고, 정의한 대로 **검증(validate)**하거나 conform하고, 심지어 정의만으로 샘플 데이터를 생성할 수도 있어요.

출처: Clojure 공식문서

본문

시작하기

spec 라이브러리는 데이터의 구조를 정의하고, 그것을 검증 또는 conform하며, 그 spec에 기반해 데이터를 생성해요.

사용하려면 Clojure 1.9.0 이상에 의존성을 선언하면 돼요.

[org.clojure/clojure "1.12.0"]

REPL에서 clojure.spec.alpha 네임스페이스를 require해서 작업을 시작해요.

(require '[clojure.spec.alpha :as s])

혹은 네임스페이스에 포함시킬 수도 있어요.

(ns my.ns
  (:require [clojure.spec.alpha :as s]))

Predicate

각 spec은 허용되는 값의 집합을 설명해요. spec을 만드는 방법은 여러 가지인데, 그 모두를 조합해 더 정교한 spec을 만들 수 있어요.

인자를 하나 받아 truthy 값을 돌려주는 Clojure 함수는 전부 유효한 predicate spec이 돼요. 특정 데이터 값이 spec에 맞는지 conform으로 확인할 수 있어요.

(s/conform even? 1000)
;;=> 1000

conform은 spec이 될 수 있는 것과 데이터 값을 받아요. 여기서는 predicate를 넘겼는데, 이 predicate가 암묵적으로 spec으로 변환돼요. 반환값은 "conformed"된 값이에요. 여기서는 conform된 값이 원래 값과 같죠 — 나중에 언제부터 달라지는지 볼 거예요. 값이 spec에 맞지 않으면 특별한 값 :clojure.spec.alpha/invalid가 반환돼요.

conform된 값을 쓰지 않고 :clojure.spec.alpha/invalid를 검사하는 것도 귀찮다면, 불리언을 돌려주는 valid? 헬퍼를 쓰면 돼요.

(s/valid? even? 10)
;;=> true

여기서도 valid?가 predicate 함수를 암묵적으로 spec으로 변환해 줘요. spec 라이브러리는 이미 갖고 있는 함수를 그대로 활용할 수 있게 해 줘요 — 별도의 predicate 사전 같은 건 없어요. 몇 가지 예를 더 볼게요.

(s/valid? nil? nil)  ;; true
(s/valid? string? "abc")  ;; true

(s/valid? #(> % 5) 10) ;; true
(s/valid? #(> % 5) 0) ;; false

(import java.util.Date)
(s/valid? inst? (Date.))  ;; true

집합(set)도 하나 이상의 리터럴 값을 매칭하는 predicate로 쓰일 수 있어요.

(s/valid? #{:club :diamond :heart :spade} :club) ;; true
(s/valid? #{:club :diamond :heart :spade} 42) ;; false

(s/valid? #{42} 42) ;; true

레지스트리 (Registry)

지금까지는 spec을 직접 사용했어요. 그런데 spec은 재사용 가능한 spec을 전역으로 선언해 두는 중앙 레지스트리를 제공해요. 레지스트리는 네임스페이스가 붙은 키워드(namespaced keyword)와 spec을 연결해요. 네임스페이스를 쓰기 때문에 서로 다른 라이브러리나 애플리케이션에서도 충돌 없이 재사용 가능한 spec을 정의할 수 있어요.

spec은 s/def로 등록해요. 등록할 네임스페이스는 직접 정하는데, 보통은 자신이 관리하는 네임스페이스에 두는 게 좋아요.

(s/def :order/date inst?)
(s/def :deck/suit #{:club :diamond :heart :spade})

등록된 spec 식별자는 지금까지 본 conform, valid? 같은 연산에서 spec 정의 자리에 그대로 쓸 수 있어요.

(s/valid? :order/date (Date.))
;;=> true
(s/conform :deck/suit :club)
;;=> :club

나중에 보겠지만, 등록된 spec은 spec을 조합하는 어디에서든 쓸 수 있고(그리고 써야 해요).

Spec 이름

spec 이름은 항상 완전히 자격을 갖춘 키워드(full-qualified keyword)예요. 일반적으로 Clojure 코드는 다른 라이브러리가 제공하는 spec과 충돌하지 않도록 충분히 독특한 키워드 네임스페이스를 써야 해요. 공개용 라이브러리를 만든다면 spec 네임스페이스에 프로젝트 이름이나 URL, 조직 이름을 포함하는 게 좋아요. 사내(사설 조직)에서는 더 짧은 이름을 써도 되는데, 가장 중요한 건 충돌을 피할 만큼 충분히 독특해야 한다는 점이에요.

이 가이드에서는 예시가 길어지지 않도록 짧은 자격 이름을 자주 사용할 거예요.

spec이 레지스트리에 등록되면 doc이 그것을 찾아 출력해 줘요.

(doc :order/date)
-------------------------
:order/date
Spec
  inst?

(doc :deck/suit)
-------------------------
:deck/suit
Spec
  #{:spade :heart :diamond :club}

predicate 조합하기

spec을 조합하는 가장 간단한 방법은 andor를 쓰는 거예요. s/and로 여러 predicate를 하나의 복합 spec으로 합쳐 볼게요.

(s/def :num/big-even (s/and int? even? #(> % 1000)))
(s/valid? :num/big-even :foo) ;; false
(s/valid? :num/big-even 10) ;; false
(s/valid? :num/big-even 100000) ;; true

s/or로 두 개의 대안을 지정할 수도 있어요.

(s/def :domain/name-or-id (s/or :name string? 
                                :id   int?))
(s/valid? :domain/name-or-id "abc") ;; true
(s/valid? :domain/name-or-id 100) ;; true
(s/valid? :domain/name-or-id :foo) ;; false

or spec은 유효성 검사 중 선택이 일어나는 첫 번째 경우예요. 각 선택지는 태그(여기서는 :name:id)로 표시되고, 이 태그 덕분에 conform이나 다른 spec 함수가 돌려주는 데이터를 이해하거나 풍부하게 만들 수 있어요.

or를 conform하면 태그 이름과 conform된 값을 담은 벡터가 반환돼요.

(s/conform :domain/name-or-id "abc")
;;=> [:name "abc"]
(s/conform :domain/name-or-id 100)
;;=> [:id 100]

인스턴스의 타입을 검사하는 많은 predicate(string?, number?, keyword? 등)는 nil을 유효한 값으로 허용하지 않아요. nil도 유효한 값으로 포함하려면 제공되는 nilable 함수로 spec을 만들어요.

(s/valid? string? nil)
;;=> false
(s/valid? (s/nilable string?) nil)
;;=> true

Explain

explain은 spec의 또 다른 고수준 연산으로, 값이 spec에 맞지 않는 이유를 (*out*으로) 보고해 줘요. 지금까지 본 몇 가지 non-conforming 예제를 explain이 뭐라고 말하는지 보죠.

(s/explain :deck/suit 42)
;; 42 - failed: #{:spade :heart :diamond :club} spec: :deck/suit
(s/explain :num/big-even 5)
;; 5 - failed: even? spec: :num/big-even
(s/explain :domain/name-or-id :foo)
;; :foo - failed: string? at: [:name] spec: :domain/name-or-id
;; :foo - failed: int? at: [:id] spec: :domain/name-or-id

마지막 예제의 출력을 좀 더 자세히 볼게요. 먼저 두 개의 오류가 보고되고 있어요 — spec은 가능한 모든 대안을 평가하고 모든 경로의 오류를 보고해요. 각 오류가 갖는 부분은 이래요.

  • val — 사용자 입력에서 매칭되지 않은 값
  • spec — 평가되고 있던 spec
  • at — 오류가 발생한 spec 안에서의 위치를 나타내는 경로(키워드의 벡터). 경로의 태그는 spec 안에서 태그된 부분을 가리켜요 — oralt의 대안, cat의 부분, 맵의 키 등
  • predicateval이 만족시키지 못한 실제 predicate
  • in — 실패한 값까지 내려가는 중첩 데이터 값의 키 경로. 이 예제에서는 최상위 값이 실패하는 상황이라 사실상 빈 경로라 생략돼요.

첫 번째 오류를 보면 값 :foo가 spec :domain/name-or-id:name 경로에서 predicate string?을 만족시키지 못했어요. 두 번째 오류도 비슷한데 :id 경로에서 실패해요. 실제 값이 키워드라 어느 쪽도 매칭되지 않죠.

explain에 더해, 오류 메시지를 문자열로 받으려면 explain-str, 오류를 데이터로 받으려면 explain-data를 쓸 수 있어요.

(s/explain-data :domain/name-or-id :foo)
;;=> #:clojure.spec.alpha{
;;     :problems ({:path [:name], 
;;                 :pred clojure.core/string?,
;;                 :val :foo,
;;                 :via [:domain/name-or-id],
;;                 :in []}
;;                {:path [:id],
;;                 :pred clojure.core/int?,
;;                 :val :foo,
;;                 :via [:domain/name-or-id],
;;                 :in []})}

이 결과는 Clojure 1.9에 추가된 네임스페이스 맵 리터럴 문법을 보여주기도 해요. 맵에 #: 또는 #::(autoresolve용)을 붙이면 맵의 모든 키에 기본 네임스페이스를 지정할 수 있어요. 이 예제에서 {:clojure.spec.alpha/problems ...}와 동일하죠.

엔티티 맵 (Entity Maps)

Clojure 프로그램은 데이터 맵을 주고받는 일이 매우 많아요. 다른 라이브러리에서 흔한 접근은 각 엔티티 타입을, 그것이 담는 키와 그 값의 구조까지 합쳐서 설명하는 거예요. spec은 그 대신 엔티티(맵)의 범위 안에서 속성(key+value) spec을 정의하지 않고, 개별 속성에 의미를 부여한 다음 그 속성들을 맵으로 모을 때 **집합(set) 의미론(키 기준)**을 사용해요. 이 접근 덕분에 라이브러리와 애플리케이션을 가로질러 속성 수준에서 의미론을 부여하고 공유할 수 있어요.

예를 들어 대부분의 Ring 미들웨어 함수는 요청이나 응답 맵을 자격이 없는(unqualified) 키로 수정해요. 하지만 각 미들웨어가 그 키에 등록된 의미론을 가진 네임스페이스 키를 사용할 수도 있어요. 그 키들에 대해 conformance를 검사하면, 협업과 일관성의 기회가 더 큰 시스템이 만들어져요.

spec에서 엔티티 맵은 keys로 정의해요.

(def email-regex #"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$")
(s/def :acct/email-type (s/and string? #(re-matches email-regex %)))

(s/def :acct/acctid int?)
(s/def :acct/first-name string?)
(s/def :acct/last-name string?)
(s/def :acct/email :acct/email-type)

(s/def :acct/person (s/keys :req [:acct/first-name :acct/last-name :acct/email]
                            :opt [:acct/phone]))

이것은 필수 키로 :acct/first-name, :acct/last-name, :acct/email을, 선택 키로 :acct/phone을 가진 :acct/person spec을 등록해요. 맵 spec은 속성의 값 spec을 지정하지 않고, 어떤 속성이 필수/선택인지만 지정해요.

맵에 대해 conformance를 검사할 때 두 가지 일이 벌어져요 — 필수 속성이 포함됐는지 검사하고, 등록된 모든 키가 conform하는 값을 갖는지 검사해요. 선택 속성이 언제 유용한지는 나중에 보게 될 거예요. 또 keys:req:opt에 나열된 키뿐 아니라 모든 속성을 검사한다는 점을 기억하세요. 그래서 맨 (s/keys)도 유효한데, 어떤 키가 필수/선택인지 검사하지 않고 맵의 모든 속성을 검사해요.

(s/valid? :acct/person
  {:acct/first-name "Bugs"
   :acct/last-name "Bunny"
   :acct/email "[email protected]"})
;;=> true

;; Fails required key check
(s/explain :acct/person
  {:acct/first-name "Bugs"})
;; #:acct{:first-name "Bugs"} - failed: (contains? % :acct/last-name) 
;;   spec: :acct/person
;; #:acct{:first-name "Bugs"} - failed: (contains? % :acct/email)
;;   spec: :acct/person

;; Fails attribute conformance
(s/explain :acct/person
  {:acct/first-name "Bugs"
   :acct/last-name "Bunny"
   :acct/email "n/a"})
;; "n/a" - failed: (re-matches email-regex %) in: [:acct/email]
;;   at: [:acct/email] spec: :acct/email-type

마지막 예제의 explain 오류 출력을 잠깐 살펴볼게요.

  • in — 데이터 안에서 실패한 값까지의 경로 (여기서는 person 인스턴스의 키)
  • val — 실패한 값, 여기서는 "n/a"
  • spec — 실패한 spec, 여기서는 :acct/email-type
  • at — 실패한 값이 위치한 spec 안에서의 경로
  • predicate — 실패한 predicate, 여기서는 (re-matches email-regex %)

기존 Clojure 코드 중 상당수는 네임스페이스 키가 아닌 맵을 쓰기 때문에, keys는 필수/선택 자격 없는 키를 위해 :req-un:opt-un도 지정할 수 있어요. 이 변형들은 spec을 찾는 데는 네임스페이스 키를 사용하지만, 맵이 검사하는 것은 자격이 없는 버전의 키예요.

아까 등록한 네임스페이스 spec들에 대해 자격 없는 키를 쓰는 person 맵을 준수하는지 검사해 볼게요.

(s/def :unq/person 
  (s/keys :req-un [:acct/first-name :acct/last-name :acct/email]
          :opt-un [:acct/phone]))

(s/conform :unq/person
  {:first-name "Bugs"
   :last-name "Bunny"
   :email "[email protected]"})
;;=> {:first-name "Bugs", :last-name "Bunny", :email "[email protected]"}

(s/explain :unq/person
  {:first-name "Bugs"
   :last-name "Bunny"
   :email "n/a"})
;; "n/a" - failed: (re-matches email-regex %) in: [:email] at: [:email]
;;   spec: :acct/email-type

(s/explain :unq/person
  {:first-name "Bugs"})
;; {:first-name "Bugs"} - failed: (contains? % :last-name) spec: :unq/person
;; {:first-name "Bugs"} - failed: (contains? % :email) spec: :unq/person

자격 없는 키는 레코드(record) 속성 검증에도 쓸 수 있어요.

(defrecord Person [first-name last-name email phone])

(s/explain :unq/person
           (->Person "Bugs" nil nil nil))
;; nil - failed: string? in: [:last-name] at: [:last-name] spec: :acct/last-name
;; nil - failed: string? in: [:email] at: [:email] spec: :acct/email-type

(s/conform :unq/person
  (->Person "Bugs" "Bunny" "[email protected]" nil))
;;=> #user.Person{:first-name "Bugs", :last-name "Bunny",
;;=>              :email "[email protected]", :phone nil}

Clojure에서 흔한 패턴 중 하나는 키워드 키와 값을 순차 데이터 구조에 옵션으로 넘기는 "키워드 인자(keyword args)"예요. spec은 이 패턴을 정규식 연산자 keys*로 특별 지원해요. keys*keys와 똑같은 문법과 의미를 갖지만 순차 정규식 구조 안에 끼워 넣을 수 있어요.

(s/def :my.config/port number?)
(s/def :my.config/host string?)
(s/def :my.config/id keyword?)
(s/def :my.config/server (s/keys* :req [:my.config/id :my.config/host] 
                                  :opt [:my.config/port]))
(s/conform :my.config/server [:my.config/id :s1
                              :my.config/host "example.com"
                              :my.config/port 5555])
;;=> #:my.config{:id :s1, :host "example.com", :port 5555}

엔티티 맵을 여러 부분으로 나눠 선언하고 싶을 때가 있어요 — 엔티티 맵의 요구 조건이 서로 다른 출처에서 오거나, 공통 키와 타입별 변형 부분이 있는 경우죠. s/merge spec은 여러 s/keys spec을 하나로 합쳐 그 요구 조건들을 결합할 수 있게 해 줘요. 예를 들어 동물의 공통 속성과 개(dog) 특화 속성을 정의하는 두 개의 keys spec을 생각해 봐요. 개 엔티티 자체는 그 두 속성 집합의 merge로 설명할 수 있어요.

(s/def :animal/kind string?)
(s/def :animal/says string?)
(s/def :animal/common (s/keys :req [:animal/kind :animal/says]))
(s/def :dog/tail? boolean?)
(s/def :dog/breed string?)
(s/def :animal/dog (s/merge :animal/common
                            (s/keys :req [:dog/tail? :dog/breed])))
(s/valid? :animal/dog
  {:animal/kind "dog"
   :animal/says "woof"
   :dog/tail? true
   :dog/breed "retriever"})
;;=> true

multi-spec

Clojure에서 또 흔한 패턴은 맵을 태그된(tagged) 엔티티로 쓰면서, 맵의 "타입"을 나타내는 특별한 필드를 두는 거예요. 여기서 타입은 잠재적으로 열린(open) 타입 집합을 가리키고, 종종 타입 간 공유 속성이 있죠.

앞서 논의했듯 타입 전체의 속성은 네임스페이스 키워드로 레지스트리에 저장된 속성을 써서 잘 지정할 수 있어요. 엔티티 타입 간에 공유되는 속성은 자동으로 공유 의미론을 얻어요. 하지만 타입 별로 필수 키를 지정하고 싶어요. 그렇게 하기 위해 spec은 multi-spec을 제공하는데, multimethod를 활용해 타입 태그에 기반한 열린 엔티티 타입 집합을 지정할 수 있게 해 줘요.

예를 들어 공통 필드를 공유하면서도 타입별 모양을 갖는 이벤트 객체를 받는 API를 상상해 봐요. 먼저 이벤트 속성을 등록해요.

(s/def :event/type keyword?)
(s/def :event/timestamp int?)
(s/def :search/url string?)
(s/def :error/message string?)
(s/def :error/code int?)

그다음 셀렉터(여기서는 :event/type 필드)를 고르는 dispatch 함수를 정의하고 값에 따라 적절한 spec을 돌려주는 multimethod가 필요해요.

(defmulti event-type :event/type)
(defmethod event-type :event/search [_]
  (s/keys :req [:event/type :event/timestamp :search/url]))
(defmethod event-type :event/error [_]
  (s/keys :req [:event/type :event/timestamp :error/message :error/code]))

이 메서드들은 인자를 무시하고 지정된 타입에 대한 spec을 돌려줘야 해요. 여기서 "search" 이벤트와 "error" 이벤트, 두 가지 가능한 이벤트를 완전히 spec했습니다.

그리고 마지막으로 multi-spec을 선언하고 사용해 볼 준비가 됐어요.

(s/def :event/event (s/multi-spec event-type :event/type))

(s/valid? :event/event
  {:event/type :event/search
   :event/timestamp 1463970123000
   :search/url "https://clojure.org"})
;=> true
(s/valid? :event/event
  {:event/type :event/error
   :event/timestamp 1463970123000
   :error/message "Invalid host"
   :error/code 500})
;=> true
(s/explain :event/event
  {:event/type :event/restart})
;; #:event{:type :event/restart} - failed: no method at: [:event/restart] 
;;   spec: :event/event
(s/explain :event/event
  {:event/type :event/search
   :search/url 200})
;; 200 - failed: string? in: [:search/url] 
;;   at: [:event/search :search/url] spec: :search/url
;; {:event/type :event/search, :search/url 200} - failed: (contains? % :event/timestamp) 
;;   at: [:event/search] spec: :event/event

마지막 예제의 explain 출력을 잠깐 살펴볼게요. 두 종류의 실패가 감지됐어요. 첫 번째 실패는 이벤트에서 필수 키인 :event/timestamp가 빠진 거예요. 두 번째는 :search/url 값이 잘못된 것(문자열 대신 숫자)이에요. 이전 explain 오류와 같은 부분이 보여요.

  • in — 데이터 안에서 실패한 값까지의 경로. 첫 번째 오류는 루트 값에서 실패한거라 생략됐고, 두 번째 오류에서는 맵의 키예요
  • val — 실패한 값, 맵 전체거나 맵 안의 개별 키
  • spec — 실제로 실패한 spec
  • at — 실패한 값이 발생한 spec 안의 경로
  • predicate — 실제로 실패한 predicate

multi-spec 접근법은 multimethod와 프로토콜처럼 spec 검증을 위한 열린 시스템을 만들 수 있게 해 줘요. 새 이벤트 타입은 나중에 event-type multimethod를 확장하기만 하면 추가할 수 있어요.

컬렉션 (Collections)

다른 특별한 컬렉션 케이스를 위한 헬퍼 몇 가지가 제공돼요 — coll-of, tuple, map-of가 그것이에요.

임의 크기의 동질(homogenous) 컬렉션 특별 케이스에는 coll-of를 사용해 predicate를 만족하는 원소의 컬렉션을 지정할 수 있어요.

(s/conform (s/coll-of keyword?) [:a :b :c])
;;=> [:a :b :c]
(s/conform (s/coll-of number?) #{5 10 2})
;;=> #{2 5 10}

추가로 coll-of에 여러 키워드 인자 옵션을 넘길 수 있어요.

  • :kind — 들어오는 컬렉션이 만족해야 하는 predicate, 예: vector?
  • :count — 정확한 기대 개수 지정
  • :min-count, :max-count — 컬렉션이 (<= min-count count max-count)인지 검사
  • :distinct — 모든 원소가 서로 다른지 검사
  • :into — 출력 conform 값으로 쓸 [], (), {}, #{} 중 하나. :into를 지정하지 않으면 입력 컬렉션 타입이 사용돼요

다음은 이 옵션들을 활용해 세 개의 서로 다른 숫자를 담은 벡터를 집합으로 conform하는 예제와, 여러 종류의 잘못된 값에 대한 오류들이에요.

(s/def :ex/vnum3 (s/coll-of number? :kind vector? :count 3 :distinct true :into #{}))
(s/conform :ex/vnum3 [1 2 3])
;;=> #{1 2 3}
(s/explain :ex/vnum3 #{1 2 3})   ;; not a vector
;; #{1 3 2} - failed: vector? spec: :ex/vnum3
(s/explain :ex/vnum3 [1 1 1])    ;; not distinct
;; [1 1 1] - failed: distinct? spec: :ex/vnum3
(s/explain :ex/vnum3 [1 2 :a])   ;; not a number
;; :a - failed: number? in: [2] spec: :ex/vnum3

coll-ofmap-of는 모든 원소를 conform하기 때문에 큰 컬렉션에는 부적합할 수 있어요. 그런 경우에는 every나, 맵의 경우 every-kv를 고려해 보세요.

coll-of는 임의 크기의 동질 컬렉션에 좋지만, 또 다른 경우는 서로 다른 위치에 알려진 타입의 필드가 있는 고정 크기의 위치 기반 컬렉션이에요. 그 경우에는 tuple을 써요.

(s/def :geom/point (s/tuple double? double? double?))
(s/conform :geom/point [1.5 2.5 -0.5])
=> [1.5 2.5 -0.5]

x/y/z 값을 가진 "point" 구조의 이 경우, 실제로 세 가지 spec 중 하나를 선택할 수 있었어요.

  • 정규식 — (s/cat :x double? :y double? :z double?)
    • 중첩 구조 매칭 허용 (여기서는 불필요)
    • cat 태그에 기반한 이름 키를 가진 맵으로 conform
  • 컬렉션 — (s/coll-of double?)
    • 임의 크기의 동질 컬렉션용으로 설계
    • 값의 벡터로 conform
  • 튜플 — (s/tuple double? double? double?)
    • 알려진 위치 "필드"가 있는 고정 크기용으로 설계
    • 값의 벡터로 conform

이 예제에서 coll-of는 다른 (잘못된) 값([1.0]이나 [1.0 2.0 3.0 4.0] 같은)도 매칭하므로 적합하지 않아요 — 우리는 고정 필드를 원하니까요. 정규식과 튜플 사이의 선택은 어느 정도 취향의 문제인데, 태그된 반환값이나 오류 출력 중 어느 쪽이 더 나은지에 따라 달라질 수 있어요.

keys가 정보 맵을 지원하는 데 더해, spec은 동질 키와 값 predicate를 가진 맵을 위한 map-of도 제공해요.

(s/def :game/scores (s/map-of string? int?))
(s/conform :game/scores {"Sally" 1000, "Joe" 500})
;=> {"Sally" 1000, "Joe" 500}

기본적으로 map-of는 키를 검증만 하고 conform하지는 않아요 — conform된 키가 중복 키를 만들어 맵의 항목이 덮어써질 수 있기 때문이에요. conform 키가 필요하면 :conform-keys true 옵션을 넘기세요.

map-of에도 coll-of에서 쓰는 다양한 count 관련 옵션을 쓸 수 있어요.

시퀀스 (Sequences)

때로는 순차 데이터가 추가 구조를 인코딩할 때가 있어요 (보통 새 문법이며, 종종 매크로에서 사용돼요). spec은 순차 데이터 값의 구조를 설명하는 표준 정규식 연산자를 제공해요.

  • cat — predicate/패턴의 연결
  • alt — 대체 predicate/패턴 사이의 선택
  • * — predicate/패턴의 0개 이상
  • + — predicate/패턴의 1개 이상
  • ? — predicate/패턴의 0개 또는 1개

or처럼 catalt도 그 "부분"에 태그를 달아요 — 이 태그들은 conform된 값에서 무엇이 매칭됐는지 식별하고, 오류를 보고하는 데 쓰여요.

수량(숫자)과 단위(키워드)를 담은 벡터로 표현되는 재료(ingredient)를 생각해 봐요. 이 데이터의 spec은 cat을 사용해 올바른 구성 요소를 올바른 순서로 지정해요. predicate처럼 정규식 연산자도 conform, valid? 같은 함수에 넘겨지면 암묵적으로 spec으로 변환돼요.

(s/def :cook/ingredient (s/cat :quantity number? :unit keyword?))
(s/conform :cook/ingredient [2 :teaspoon])
;;=> {:quantity 2, :unit :teaspoon}

데이터는 태그를 키로 한 맵으로 conform돼요. explain으로 non-conforming 데이터를 검사할 수 있어요.

;; pass string for unit instead of keyword
(s/explain :cook/ingredient [11 "peaches"])
;; "peaches" - failed: keyword? in: [1] at: [:unit] spec: :cook/ingredient

;; leave out the unit
(s/explain :cook/ingredient [2])
;; () - failed: Insufficient input at: [:unit] spec: :cook/ingredient

이제 발생 연산자 *, +, ?를 각각 볼게요.

(s/def :ex/seq-of-keywords (s/* keyword?))
(s/conform :ex/seq-of-keywords [:a :b :c])
;;=> [:a :b :c]
(s/explain :ex/seq-of-keywords [10 20])
;; 10 - failed: keyword? in: [0] spec: :ex/seq-of-keywords

(s/def :ex/odds-then-maybe-even (s/cat :odds (s/+ odd?)
                                       :even (s/? even?)))
(s/conform :ex/odds-then-maybe-even [1 3 5 100])
;;=> {:odds [1 3 5], :even 100}
(s/conform :ex/odds-then-maybe-even [1])
;;=> {:odds [1]}
(s/explain :ex/odds-then-maybe-even [100])
;; 100 - failed: odd? in: [0] at: [:odds] spec: :ex/odds-then-maybe-even

;; opts are alternating keywords and booleans
(s/def :ex/opts (s/* (s/cat :opt keyword? :val boolean?)))
(s/conform :ex/opts [:silent? false :verbose true])
;;=> [{:opt :silent?, :val false} {:opt :verbose, :val true}]

마지막으로 alt를 사용해 순차 데이터 안에서 대안을 지정할 수 있어요. cat처럼 alt도 각 대안에 태그를 달아야 하고, conform된 데이터는 태그와 값의 벡터예요.

(s/def :ex/config (s/* 
                    (s/cat :prop string?
                           :val  (s/alt :s string? :b boolean?))))
(s/conform :ex/config ["-server" "foo" "-verbose" true "-user" "joe"])
;;=> [{:prop "-server", :val [:s "foo"]}
;;    {:prop "-verbose", :val [:b true]}
;;    {:prop "-user", :val [:s "joe"]}]

spec에 대한 설명이 필요하면 describe로 가져올 수 있어요. 이미 정의한 몇 가지 spec에 시도해 볼게요.

(s/describe :ex/seq-of-keywords)
;;=> (* keyword?)
(s/describe :ex/odds-then-maybe-even)
;;=> (cat :odds (+ odd?) :even (? even?))
(s/describe :ex/opts)
;;=> (* (cat :opt keyword? :val boolean?))

spec은 추가 정규식 연산자 하나를 더 정의하는데, 바로 &예요. 정규식 연산자를 받아 하나 이상의 추가 predicate로 제약을 걸어요. 이걸로 별도의 커스텀 predicate가 필요한 추가 제약을 가진 정규식을 만들 수 있어요. 예를 들어 문자열이 짝수 개인 시퀀스만 매칭하고 싶다고 해 보죠.

(s/def :ex/even-strings (s/& (s/* string?) #(even? (count %))))
(s/valid? :ex/even-strings ["a"])  ;; false
(s/valid? :ex/even-strings ["a" "b"])  ;; true
(s/valid? :ex/even-strings ["a" "b" "c"])  ;; false
(s/valid? :ex/even-strings ["a" "b" "c" "d"])  ;; true

정규식 연산자를 조합하면 하나의 시퀀스를 설명해요. 중첩된 순차 컬렉션을 spec하려면 새 중첩 정규식 컨텍스트를 시작하기 위해 spec을 명시적으로 호출해야 해요. 예를 들어 [:names ["a" "b"] :nums [1 2 3]] 같은 시퀀스를 설명하려면 안쪽 순차 데이터를 설명하는 중첩 정규식이 필요해요.

(s/def :ex/nested
  (s/cat :names-kw #{:names}
         :names (s/spec (s/* string?))
         :nums-kw #{:nums}
         :nums (s/spec (s/* number?))))
(s/conform :ex/nested [:names ["a" "b"] :nums [1 2 3]])
;;=> {:names-kw :names, :names ["a" "b"], :nums-kw :nums, :nums [1 2 3]}

만약 그 spec들이 없었다면 이 spec은 [:names "a" "b" :nums 1 2 3] 같은 시퀀스를 매칭했을 거예요.

(s/def :ex/unnested
  (s/cat :names-kw #{:names}
         :names (s/* string?)
         :nums-kw #{:nums}
         :nums (s/* number?)))
(s/conform :ex/unnested [:names "a" "b" :nums 1 2 3])
;;=> {:names-kw :names, :names ["a" "b"], :nums-kw :nums, :nums [1 2 3]}

검증을 위한 spec 사용하기

이쯤에서 한 걸음 물러나 spec을 어떻게 런타임 데이터 검증에 쓸 수 있는지 생각해 볼 때예요.

spec을 쓰는 한 가지 방법은 함수에 전달되는 입력 데이터를 검증하도록 valid?를 명시적으로 호출하는 거예요. 예를 들어 defn에 내장된 기존 전제(pre-)/후제(post-) 조건 지원을 쓸 수 있어요.

(defn person-name
  [person]
  {:pre [(s/valid? :acct/person person)]
   :post [(s/valid? string? %)]}
  (str (:acct/first-name person) " " (:acct/last-name person)))

(person-name 42)
;; Execution error (AssertionError) at user/person-name (REPL:1).
;; Assert failed: (s/valid? :acct/person person)

(person-name {:acct/first-name "Bugs" 
              :acct/last-name "Bunny" 
			  :acct/email "[email protected]"})
;;=> "Bugs Bunny"

유효한 :acct/person 데이터가 아닌 걸 함수에 넘기면 전제 조건이 실패해요. 마찬가지로 코드에 버그가 있어 출력이 문자열이 아니라면 후제 조건이 실패할 거예요.

또 다른 옵션은 코드 안에서 s/assert를 사용해 값이 spec을 만족한다고 단언하는 거예요. 성공하면 값이 반환되고, 실패하면 assertion 오류가 던져져요. 기본적으로 assertion 검사는 꺼져 있어요 — REPL에서 s/check-asserts로 바꾸거나 시작 시 시스템 프로퍼티 clojure.spec.check-asserts=true를 설정해 켤 수 있어요.

(defn person-name
  [person]
  (let [p (s/assert :acct/person person)]
    (str (:acct/first-name p) " " (:acct/last-name p))))

(s/check-asserts true)
(person-name 100)
;; Execution error - invalid arguments to user/person-name at (REPL:3).
;; 100 - failed: map?

더 깊은 수준의 통합은 conform을 호출하고 그 반환값을 destructuring과 함께 사용해 입력을 분해하는 거예요. 대안이 있는 복잡한 입력에서 특히 유용해요.

아까 정의한 config spec을 사용해 conform해 볼게요.

(defn- set-config [prop val]
  ;; dummy fn
  (println "set" prop val))

(defn configure [input]
  (let [parsed (s/conform :ex/config input)]
    (if (s/invalid? parsed)
      (throw (ex-info "Invalid input" (s/explain-data :ex/config input)))
      (for [{prop :prop [_ val] :val} parsed]
        (set-config (subs prop 1) val)))))

(configure ["-server" "foo" "-verbose" true "-user" "joe"])

여기서 configureconform을 호출해 config 입력을 destructuring하기 좋은 데이터를 만들어요. 결과는 특별한 ::s/invalid 값이거나 주석이 달린 형태의 결과예요.

[{:prop "-server", :val [:s "foo"]} 
 {:prop "-verbose", :val [:b true]} 
 {:prop "-user", :val [:s "joe"]}]

성공 케이스에서 파싱된 입력은 더 처리하기 좋은 원하는 모양으로 변형돼요. 오류 케이스에서는 explain-data를 호출해 오류 메시지 데이터를 만들죠. explain 데이터에는 어떤 표현식이 conform에 실패했는지, 그 표현식의 spec 안에서의 경로, 매칭하려던 predicate에 대한 정보가 들어 있어요.

함수 spec 만들기

이전 섹션의 전제/후제 조건 예제가 흥미로운 질문을 암시했어요 — 함수나 매크로의 입력·출력 spec을 어떻게 정의할까?

spec은 이를 위해 fdef를 명시적으로 지원해요. 함수의 spec — 인자 및/또는 반환값 spec, 그리고 선택적으로 인자와 반환값 사이의 관계를 지정할 수 있는 함수 — 을 정의하죠.

범위 안에서 난수를 만드는 ranged-rand 함수를 생각해 봐요.

(defn ranged-rand
  "Returns random int in range start <= rand < end"
  [start end]
  (+ start (long (rand (- end start)))))

그런 다음 그 함수에 대한 spec을 제공할 수 있어요.

(s/fdef ranged-rand
  :args (s/and (s/cat :start int? :end int?)
               #(< (:start %) (:end %)))
  :ret int?
  :fn (s/and #(>= (:ret %) (-> % :args :start))
             #(< (:ret %) (-> % :args :end))))

이 함수 spec은 여러 기능을 보여줘요. 먼저 :args는 함수 인자를 설명하는 복합 spec이에요. 이 spec은 인자가 리스트로 (apply fn (arg-list))처럼 넘겨질 때처럼 호출돼요. 인자가 순차적이고 위치 기반 필드이기 때문에 cat, alt, * 같은 정규식 연산자로 거의 항상 설명돼요.

두 번째 :args predicate는 첫 번째 predicate의 conform 결과를 입력으로 받아 start < end인지 검증해요. :ret spec은 반환값도 정수임을 나타내요. 마지막으로 :fn spec은 반환값이 >= start이고 < end인지 검사해요.

함수에 대한 spec이 만들어지면 그 함수의 doc에도 포함돼요.

(doc ranged-rand)
-------------------------
user/ranged-rand
([start end])
  Returns random int in range start <= rand < end
Spec
  args: (and (cat :start int? :end int?) (< (:start %) (:end %)))
  ret: int?
  fn: (and (>= (:ret %) (-> % :args :start)) (< (:ret %) (-> % :args :end)))

나중에 함수 spec을 개발과 테스트에 어떻게 쓰는지 볼 거예요.

고차 함수 (Higher order functions)

고차 함수는 Clojure에서 흔하고, spec은 이를 지원하는 fspec을 제공해요.

예를 들어 adder 함수를 생각해 봐요.

(defn adder [x] #(+ x %))

adder는 x를 더하는 함수를 반환해요. fspec을 사용해 반환값에 대한 adder의 함수 spec을 선언할 수 있어요.

(s/fdef adder
  :args (s/cat :x number?)
  :ret (s/fspec :args (s/cat :y number?)
                :ret number?)
  :fn #(= (-> % :args :x) ((:ret %) 0)))

:ret spec은 fspec으로 반환되는 함수가 숫자를 받고 숫자를 돌려준다고 선언해요. 더 흥미로운 건 :fn spec이 :args(여기서 x를 앎)와 adder가 반환한 함수를 호출해 얻는 결과 사이의 일반적인 속성, 즉 0을 더하면 x가 되어야 한다는 점을 기술할 수 있다는 거예요.

매크로 (Macros)

매크로는 코드를 받아 코드를 만드는 함수이므로 함수처럼 spec을 만들 수 있어요. 한 가지 특별한 고려 사항은, 인자로 평가된 값이 아니라 데이터로서의 코드를 받는다는 걸 명심해야 한다는 점이에요. 그리고 대부분 새 코드를 데이터로 만들어내기 때문에 매크로의 :ret 값은(그저 코드이므로) spec하는 게 도움이 안 되는 경우가 많아요.

예를 들어 clojure.core/declare 매크로를 이렇게 spec할 수 있어요.

(s/fdef clojure.core/declare
    :args (s/cat :names (s/* simple-symbol?))
    :ret any?)

Clojure 매크로 확장기는 매크로 확장 시점에(runtime이 아니라!) 매크로에 등록된 :args spec을 찾아 conform해요. 오류가 감지되면 오류를 설명하기 위해 explain이 호출돼요.

(declare 100)
;; Syntax error macroexpanding clojure.core/declare at (REPL:1:1).
;; 100 - failed: simple-symbol? at: [:names]

매크로는 매크로 확장 중에 항상 검사되므로 매크로 spec에는 instrument를 호출할 필요가 없어요.

카드 게임 만들어 보기

여기 카드 게임을 모델링하는 더 큰 spec 집합이 있어요.

(def suit? #{:club :diamond :heart :spade})
(def rank? (into #{:jack :queen :king :ace} (range 2 11)))
(def deck (for [suit suit? rank rank?] [rank suit]))

(s/def :game/card (s/tuple rank? suit?))
(s/def :game/hand (s/* :game/card))

(s/def :game/name string?)
(s/def :game/score int?)
(s/def :game/player (s/keys :req [:game/name :game/score :game/hand]))

(s/def :game/players (s/* :game/player))
(s/def :game/deck (s/* :game/card))
(s/def :game/game (s/keys :req [:game/players :game/deck]))

이 데이터의 일부를 스키마에 대해 검증할 수 있어요.

(def kenny
  {:game/name "Kenny Rogers"
   :game/score 100
   :game/hand []})
(s/valid? :game/player kenny)
;;=> true

혹은 잘못된 데이터에서 나올 오류를 볼 수도 있어요.

(s/explain :game/game
  {:game/deck deck
   :game/players [{:game/name "Kenny Rogers"
                   :game/score 100
                   :game/hand [[2 :banana]]}]})
;; :banana - failed: suit? in: [:game/players 0 :game/hand 0 1] 
;;   at: [:game/players :game/hand 1] spec: :game/card

이 오류는 데이터 구조 안에서 잘못된 값까지의 키 경로, 매칭되지 않은 값, 매칭하려는 spec 부분, 그 spec 안의 경로, 실패한 predicate를 나타내요.

플레이어에게 카드를 나눠주는 deal 함수가 있다면, 그 함수를 spec해 인자와 반환값이 모두 적절한 데이터 값인지 검증할 수 있어요. 또한 :fn spec을 지정해 거래 전 게임의 카드 수가 거래 후 카드 수와 같은지 검증할 수도 있어요.

(defn total-cards [{:keys [:game/deck :game/players] :as game}]
  (apply + (count deck)
    (map #(-> % :game/hand count) players)))

(defn deal [game] .... )

(s/fdef deal
  :args (s/cat :game :game/game)
  :ret :game/game
  :fn #(= (total-cards (-> % :args :game))
          (total-cards (-> % :ret))))

제너레이터 (Generators)

spec의 핵심 설계 제약 하나는 모든 spec이 그 spec에 맞는 샘플 데이터를 생성하는 제너레이터로도 동작한다는 거예요 (프로퍼티 기반 테스트의 핵심 요구사항이죠).

프로젝트 설정

spec 제너레이터는 Clojure 프로퍼티 테스트 라이브러리인 test.check에 의존해요. 하지만 이 의존성은 동적으로 로드되므로, gen, exercise, 테스팅이 아닌 다른 spec 부분은 test.check를 런타임 의존성으로 선언하지 않고 쓸 수 있어요. 이 부분(보통 테스팅 중)을 쓰려면 test.check를 개발(dev) 의존성으로 선언해야 해요.

deps.edn 프로젝트에서는 dev 얼라이어스를 만들어요.

{...
 :aliases {
   :dev {:extra-deps {org.clojure/test.check {:mvn/version "0.9.0"}}}}}

Leiningen에서는 project.clj에 이렇게 추가해요.

:profiles {:dev {:dependencies [[org.clojure/test.check "0.9.0"]]}}

Leiningen에서 dev 프로필 의존성은 테스팅 중엔 포함되지만 의존성으로 공개되거나 uber jar에 포함되지는 않아요.

Maven에서는 의존성을 test 스코프로 선언해요.

<project>
  ...
  <dependencies>
    <dependency>
      <groupId>org.clojure</groupId>
      <artifactId>test.check</artifactId>
      <version>0.9.0</version>
      <scope>test</scope>
    </dependency>
  </dependency>
</project>

코드에서 clojure.spec.gen.alpha 네임스페이스도 포함해야 해요.

(require '[clojure.spec.gen.alpha :as gen])

제너레이터 샘플링

gen 함수로 어떤 spec에 대해서든 제너레이터를 얻을 수 있어요.

gen으로 제너레이터를 얻으면 사용하는 방법이 여러 가지예요. generate로 샘플 값 하나를, sample로 샘플 시리즈를 생성할 수 있어요. 기본적인 예를 볼게요.

(gen/generate (s/gen int?))
;;=> -959
(gen/generate (s/gen nil?))
;;=> nil
(gen/sample (s/gen string?))
;;=> ("" "" "" "" "8" "W" "" "G74SmCm" "K9sL9" "82vC")
(gen/sample (s/gen #{:club :diamond :heart :spade}))
;;=> (:heart :diamond :heart :heart :heart :diamond :spade :spade :spade :club)

(gen/sample (s/gen (s/cat :k keyword? :ns (s/+ number?))))
;;=> ((:D -2.0)
;;=>  (:q4/c 0.75 -1)
;;=>  (:*!3/? 0)
;;=>  (:+k_?.p*K.*o!d/*V -3)
;;=>  (:i -1 -1 0.5 -0.5 -4)
;;=>  (:?!/! 0.515625 -15 -8 0.5 0 0.75)
;;=>  (:vv_z2.A??!377.+z1*gR.D9+G.l9+.t9/L34p -1.4375 -29 0.75 -1.25)
;;=>  (:-.!pm8bS_+.Z2qB5cd.p.JI0?_2m.S8l.a_Xtu/+OM_34* -2.3125)
;;=>  (:Ci 6.0 -30 -3 1.0)
;;=>  (:s?cw*8.t+G.OS.xh_z2!.cF-b!PAQ_.E98H4_4lSo/?_m0T*7i 4.4375 -3.5 6.0 108 0.33203125 2 8 -0.517578125 -4))

카드 게임에서 무작위 플레이어를 생성하는 건 어떨까요?

(gen/generate (s/gen :game/player))
;;=> {:game/name "sAt8r6t",
;;    :game/score 233843,
;;    :game/hand ([8 :spade] [5 :heart] [9 :club] [3 :heart])}

게임 전체를 생성하는 건요?

(gen/generate (s/gen :game/game))
;; it works! but the output is really long, so not including it here

이제 spec에서 시작해 제너레이터를 뽑고 데이터를 생성할 수 있어요. 생성된 모든 데이터는 제너레이터로 쓴 spec을 준수해요. conform된 값이 원래 값과 다른 spec(s/or, s/cat, s/alt 등을 쓰는 것)에서는 생성된 샘플 집합과 그 샘플 데이터를 conform한 결과를 함께 보는 게 유용해요.

Exercise

이를 위한 exercise는 spec에 대해 생성된 값과 conform된 값의 쌍을 돌려줘요. exercise는 기본적으로(sample처럼) 10개의 샘플을 만들지만, 두 함수 모두 생성할 샘플 수를 나타내는 숫자를 넘길 수 있어요.

(s/exercise (s/cat :k keyword? :ns (s/+ number?)) 5)
;;=>
;;([(:y -2.0) {:k :y, :ns [-2.0]}]
;; [(:_/? -1.0 0.5) {:k :_/?, :ns [-1.0 0.5]}]
;; [(:-B 0 3.0) {:k :-B, :ns [0 3.0]}]
;; [(:-!.gD*/W+ -3 3.0 3.75) {:k :-!.gD*/W+, :ns [-3 3.0 3.75]}]
;; [(:_Y*+._?q-H/-3* 0 1.25 1.5) {:k :_Y*+._?q-H/-3*, :ns [0 1.25 1.5]}])

(s/exercise (s/or :k keyword? :s string? :n number?) 5)
;;=> ([:H [:k :H]] 
;;    [:ka [:k :ka]]
;;    [-1 [:n -1]] 
;;    ["" [:s ""]]
;;    [-3.0 [:n -3.0]])

spec된 함수를 위해 exercise-fn도 있어요. 샘플 인자를 생성하고, spec된 함수를 호출해 그 인자와 반환값을 돌려줘요.

(s/exercise-fn `ranged-rand)
=>
([(-2 -1)   -2]
 [(-3 3)     0]
 [(0 1)      0]
 [(-8 -7)   -8]
 [(3 13)     7]
 [(-1 0)    -1]
 [(-69 99) -41]
 [(-19 -1)  -5]
 [(-1 1)    -1]
 [(0 65)     7])

s/and 제너레이터 사용하기

지금까지 본 제너레이터는 모두 잘 동작했지만, 추가 도움이 필요한 경우도 많아요. 흔한 케이스 하나는 predicate가 특정 타입의 값을 암묵적으로 전제하는데 spec이 그 타입을 명시하지 않는 경우예요.

(gen/generate (s/gen even?))
;; Execution error (ExceptionInfo) at user/eval1281 (REPL:1).
;; Unable to construct gen at: [] for: clojure.core$even_QMARK_@73ab3aac

이 경우 spec이 even? predicate에 대한 제너레이터를 찾지 못했어요. spec의 대부분 기본 제너레이터는 흔한 타입 predicate(문자열, 숫자, 키워드 등)에 매핑돼 있어요.

하지만 spec은 and를 통해 이 경우를 지원하도록 설계됐어요 — 첫 번째 predicate가 제너레이터를 정하고, 이후 분기는 생산된 값에 predicate를 적용하는 필터로 작동해요(test.check의 such-that 사용).

predicate를 and와 매핑된 제너레이터를 가진 predicate로 바꾸면 even?를 생성된 값의 필터로 쓸 수 있어요.

(gen/generate (s/gen (s/and int? even?)))
;;=> -15161796

여러 predicate를 사용해 생성된 값을 더 다듬을 수 있어요. 예를 들어 3의 양의 배수인 숫자만 생성하고 싶다고 해 보죠.

(defn divisible-by [n] #(zero? (mod % n)))

(gen/sample (s/gen (s/and int?
                     #(> % 0)
                     (divisible-by 3))))
;;=> (3 9 1524 3 1836 6 3 3 927 15027)

하지만 다듬기를 너무 많이 하면 아무 값도 생성하지 못하는 결과를 낳을 수 있어요. 다듬기를 구현하는 test.check의 such-that는 비교적 적은 수의 시도 안에 다듬기 predicate를 해결하지 못하면 오류를 던져요. 예를 들어 "hello"라는 단어를 포함하는 문자열을 생성하려고 시도하는 경우를 봐요.

;; hello, are you the one I'm looking for?
(gen/sample (s/gen (s/and string? #(clojure.string/includes? % "hello"))))
;; Error printing return value (ExceptionInfo) at clojure.test.check.generators/such-that-helper (generators.cljc:320).
;; Couldn't satisfy such-that predicate after 100 tries.

시간이 충분하다면(아마 꽤 많이) 제너레이터가 이런 문자열을 만들어 낼 수도 있지만, 내부의 such-that는 필터를 통과하는 값을 만들기 위해 100번의 시도만 해요. 이 경우에는 직접 나서서 커스텀 제너레이터를 제공해야 해요.

커스텀 제너레이터

자체 제너레이터를 만들면 더 좁게, 그리고/또는 생성할 값에 대해 더 명시적으로 할 자유가 생겨요. 반대로 커스텀 제너레이터는 기본 predicate에 필터링을 더하는 것보다 conform 값을 더 효율적으로 만들 수 있는 경우에 쓸 수 있어요. spec은 커스텀 제너레이터를 신뢰하지 않고, 그것들이 만든 어떤 값도 관련 spec으로 검사해 그들이 conformance를 통과함을 보장해요.

커스텀 제너레이터를 만드는 방법은 세 가지가 있는데, 선호 순서가 내림차순이에요.

  1. spec이 predicate/spec에 기반해 제너레이터를 만들게 한다
  2. clojure.spec.gen.alpha의 도구로 직접 제너레이터를 만든다
  3. test.check나 test.check 호환 라이브러리(예: test.chuck)를 쓴다

마지막 옵션은 test.check에 런타임 의존성이 필요하므로, 처음 두 옵션이 test.check를 직접 쓰는 것보다 강하게 선호돼요.

먼저 특정 네임스페이스의 키워드를 지정하는 predicate를 가진 spec을 생각해 봐요.

(s/def :ex/kws (s/and keyword? #(= (namespace %) "my.domain")))
(s/valid? :ex/kws :my.domain/name) ;; true
(gen/sample (s/gen :ex/kws)) ;; unlikely we'll generate useful keywords this way

이 spec에 대한 값을 생성하기 시작하는 가장 간단한 방법은 고정된 옵션 집합에서 spec이 제너레이터를 만들게 하는 거예요. 집합은 유효한 predicate spec이므로 하나를 만들어 그 제너레이터를 요청할 수 있어요.

(def kw-gen (s/gen #{:my.domain/name :my.domain/occupation :my.domain/id}))
(gen/sample kw-gen 5)
;;=> (:my.domain/occupation :my.domain/occupation :my.domain/name :my.domain/id :my.domain/name)

이 커스텀 제너레이터로 spec을 다시 정의하려면 with-gen을 사용해요. spec과 대체 제너레이터를 받아요.

(s/def :ex/kws (s/with-gen (s/and keyword? #(= (namespace %) "my.domain"))
                 #(s/gen #{:my.domain/name :my.domain/occupation :my.domain/id})))
(s/valid? :ex/kws :my.domain/name)  ;; true
(gen/sample (s/gen :ex/kws))
;;=> (:my.domain/occupation :my.domain/occupation :my.domain/name  ...)

with-gen(그리고 커스텀 제너레이터를 받는 다른 곳)은 제너레이터를 반환하는 인자 없는 함수를 받아 게으르게 실현되도록 해 준다는 점을 기억하세요.

이 접근의 한 가지 단점은 프로퍼티 테스팅이 정말 잘하는 것, 즉 넓은 탐색 공간에 걸쳐 데이터를 자동으로 생성해 예상치 못한 문제를 찾는 것을 놓친다는 거예요.

clojure.spec.gen.alpha 네임스페이스에는 제너레이터 "프리미티브" 함수와 그것들을 더 복잡한 제너레이터로 결합하는 "컴비네이터" 함수가 여럿 있어요.

clojure.spec.gen.alpha 네임스페이스의 거의 모든 함수는 test.check에서 같은 이름의 함수를 동적으로 로드하는 래퍼일 뿐이에요. clojure.spec.gen.alpha 제너레이터 함수가 어떻게 동작하는지 자세히 알고 싶으면 test.check 문서를 참조하세요.

이 경우 키워드가 열린 이름(open names)을 가지되 고정된 네임스페이스를 갖길 원해요. 이것을 달성하는 방법은 많지만 가장 간단한 것 중 하나는 fmap으로 생성된 문자열에 기반해 키워드를 만드는 거예요.

(def kw-gen-2 (gen/fmap #(keyword "my.domain" %) (gen/string-alphanumeric)))
(gen/sample kw-gen-2 5)
;;=> (:my.domain/ :my.domain/ :my.domain/1 :my.domain/1O :my.domain/l9p2)

gen/fmap은 적용할 함수와 제너레이터를 받아요. 그 함수는 제너레이터가 생산하는 각 샘플에 적용되므로, 한 제너레이터 위에 다른 제너레이터를 쌓을 수 있어요.

하지만 위 예제에서 문제를 발견할 수 있어요 — 제너레이터는 종종 "더 단순한" 값을 먼저 반환하도록 설계되어, 문자열 지향 제너레이터는 종종 유효한 키워드가 아닌 빈 문자열을 반환하곤 해요. such-that으로 그 특정 값을 제외하는 약간의 조정을 할 수 있어요. 필터링 조건을 지정할 수 있게 해 주죠.

(def kw-gen-3 (gen/fmap #(keyword "my.domain" %)
               (gen/such-that #(not= % "")
                 (gen/string-alphanumeric))))
(gen/sample kw-gen-3 5)
;;=> (:my.domain/O :my.domain/b :my.domain/ZH :my.domain/31 :my.domain/U)

"hello" 예제로 돌아가서, 이제 그 제너레이터를 만들 도구가 생겼어요.

(s/def :ex/hello
  (s/with-gen #(clojure.string/includes? % "hello")
    #(gen/fmap (fn [[s1 s2]] (str s1 "hello" s2))
      (gen/tuple (gen/string-alphanumeric) (gen/string-alphanumeric)))))
(gen/sample (s/gen :ex/hello))
;;=> ("hello" "ehello3" "eShelloO1" "vhello31p" "hello" "1Xhellow" "S5bhello" "aRejhellorAJ7Yj" "3hellowPMDOgv7" "UhelloIx9E")

여기서 무작위 접두사와 접미사 문자열의 튜플을 생성한 다음 그 사이에 "hello"를 넣어요.

범위 spec과 제너레이터

범위 안의 값을 spec(하고 생성)하는 게 유용한 경우가 여럿 있고, spec은 이 경우를 위해 헬퍼를 제공해요.

예를 들어 정수 값의 범위인 경우(볼링 등) int-in으로 범위를 spec해요 (끝은 배타적):

(s/def :bowling/roll (s/int-in 0 11))
(gen/sample (s/gen :bowling/roll))
;;=> (1 0 0 3 1 7 10 1 5 0)

spec은 순간(instant)의 범위를 위한 inst-in도 포함해요.

(s/def :ex/the-aughts (s/inst-in #inst "2000" #inst "2010"))
(drop 50 (gen/sample (s/gen :ex/the-aughts) 55))
;;=> (#inst"2005-03-03T08:40:05.393-00:00"
;;    #inst"2008-06-13T01:56:02.424-00:00"
;;    #inst"2000-01-01T00:00:00.610-00:00"
;;    #inst"2006-09-13T09:44:40.245-00:00"
;;    #inst"2000-01-02T10:18:42.219-00:00")

제너레이터 구현 때문에 샘플 몇 개는 거쳐야 "흥미로운" 결과가 나오므로 조금 건너뛰었어요.

마지막으로 double-in은 double 범위를 지원하고, NaN(숫자 아님), Infinity, -Infinity 같은 특별한 double 값을 검사하는 특별한 옵션도 있어요.

(s/def :ex/dubs (s/double-in :min -100.0 :max 100.0 :NaN? false :infinite? false))
(s/valid? :ex/dubs 2.9)
;;=> true
(s/valid? :ex/dubs Double/POSITIVE_INFINITY)
;;=> false
(gen/sample (s/gen :ex/dubs))
;;=> (-1.0 -1.0 -1.5 1.25 -0.5 -1.0 -3.125 -1.5625 1.25 -0.390625)

제너레이터를 더 배우고 싶다면 test.check의 튜토리얼이나 예제 문서를 읽어 보세요. clojure.spec.gen.alphaclojure.test.check.generators의 큰 부분집합이지만 전부는 아니라는 점은 명심하세요.

계측(Instrumentation)과 테스팅

spec은 clojure.spec.test.alpha 네임스페이스에 개발·테스팅 기능 집합을 제공하는데, 이렇게 포함할 수 있어요.

(require '[clojure.spec.test.alpha :as stest])

계측 (Instrumentation)

계측은 instrumented 함수에 :args spec이 호출되고 있는지 검증해서, 함수의 외부 사용에 대한 검증을 제공해요. 아까 spec한 ranged-rand 함수에 계측을 켜 볼게요.

(stest/instrument `ranged-rand)

instrument는 완전히 자격을 갖춘 심볼을 받으므로 여기서는 현재 네임스페이스의 맥락에서 그것을 해석하도록 `을 사용해요. 함수가 :args spec과 일치하지 않는 인자로 호출되면 이런 오류를 볼 수 있어요.

(ranged-rand 8 5)
Execution error - invalid arguments to user/ranged-rand at (REPL:1).
{:start 8, :end 5} - failed: (< (:start %) (:end %))

오류는 < start end를 검사하는 두 번째 args predicate에서 실패해요. :ret:fn spec은 계측으로 검사되지 않는다는 점을 기억하세요 — 구현 검증은 테스팅 시점에 이뤄져야 하니까요.

계측은 짝이 되는 unstrument 함수로 끌 수 있어요. 계측은 개발 시점과 테스팅 중에 호출 코드의 오류를 발견하는 데 유용할 가능성이 높아요. args spec 검사와 관련된 오버헤드 때문에 프로덕션에서는 계측을 쓰지 않는 것이 권장돼요.

테스팅

앞서 clojure.spec.test.alpha가 함수를 자동으로 테스트하는 도구를 제공한다고 언급했어요. 함수에 spec이 있으면 check를 사용해 그 spec으로 함수를 검사하는 테스트를 자동으로 생성할 수 있어요.

check는 함수의 :args spec에 기반해 인자를 생성하고, 함수를 호출하며, :ret:fn spec이 만족됐는지 검사해요.

(require '[clojure.spec.test.alpha :as stest])

(stest/check `ranged-rand)
;;=> ({:spec #object[clojure.spec.alpha$fspec_impl$reify__13728 ...],
;;     :clojure.spec.test.check/ret {:result true, :num-tests 1000, :seed 1466805740290},
;;     :sym spec.examples.guide/ranged-rand,
;;     :result true})

눈치 빠른 독자라면 ranged-rand에 미묘한 버그가 있다는 걸 알아챌 거예요. start와 end의 차이가 매우 크면(Long/MAX_VALUE로 표현할 수 있는 것보다 크면) ranged-rand는 IntegerOverflowException을 만들 거예요. check를 여러 번 실행하면 결국 이 경우가 발생하게 돼요.

check는 test.check에 전달해 테스트 실행에 영향을 주는 여러 옵션도 받으며, spec의 일부에 대한 제너레이터를 이름이나 경로로 오버라이드하는 옵션도 받아요.

대신 ranged-rand 코드에 실수를 넣어 start와 end를 뒤바꿨다고 가정해 봐요.

(defn ranged-rand  ;; BROKEN!
  "Returns random int in range start <= rand < end"
  [start end]
  (+ start (long (rand (- start end)))))

이 깨진 함수는 여전히 무작위 정수를 만들지만, 기대한 범위 안이 아닐 거예요. :fn spec이 var를 검사할 때 문제를 감지할 거예요.

(stest/abbrev-result (first (stest/check `ranged-rand)))
;;=> {:spec (fspec
;;            :args (and (cat :start int? :end int?) (fn* [p1__3468#] (< (:start p1__3468#) (:end p1__3468#))))
;;            :ret int?
;;            :fn (and
;;                  (fn* [p1__3469#] (>= (:ret p1__3469#) (-> p1__3469# :args :start)))
;;                  (fn* [p1__3470#] (< (:ret p1__3470#) (-> p1__3470# :args :end))))),
;;     :sym spec.examples.guide/ranged-rand,
;;     :result {:clojure.spec.alpha/problems [{:path [:fn],
;;                                             :pred (>= (:ret %) (-> % :args :start)),
;;                                             :val {:args {:start -3, :end 0}, :ret -5},
;;                                             :via [],
;;                                             :in []}],
;;              :clojure.spec.test.alpha/args (-3 0),
;;              :clojure.spec.test.alpha/val {:args {:start -3, :end 0}, :ret -5},
;;              :clojure.spec.alpha/failure :test-failed}}

check:fn spec에서 오류를 보고했어요. 넘겨진 인자가 -3과 0이고 반환값이 -5인 걸 볼 수 있는데, 기대한 범위를 벗어났죠.

네임스페이스(또는 여러 네임스페이스)의 spec된 함수를 모두 테스트하려면 enumerate-namespace로 네임스페이스의 var를 가리키는 심볼 집합을 생성해요.

(-> (stest/enumerate-namespace 'user) stest/check)

그리고 인자 없이 stest/check를 호출하면 spec된 모든 함수를 검사할 수 있어요.

checkinstrument 결합하기

instrument(:args 검사 활성화)와 check(함수 테스트 생성)는 둘 다 유용하지만, 함께 쓰면 더 깊은 테스트 커버리지를 얻을 수 있어요.

instrument는 instrumented 함수의 동작을 바꾸는 여러 옵션을 받는데, 대체(더 좁은) spec으로 바꾸기, 함수 스텁(stub)하기(:ret spec으로 결과 생성), 함수를 대체 구현으로 바꾸기 등을 지원해요.

원격 서비스를 호출하는 저수준 함수와 그것을 호출하는 고수준 함수가 있는 경우를 생각해 봐요.

;; code under test

(defn invoke-service [service request]
  ;; invokes remote service
  )

(defn run-query [service query]
  (let [{:svc/keys [result error]} (invoke-service service {:svc/query query})]
    (or result error)))

이 함수들을 다음 spec으로 spec할 수 있어요.

(s/def :svc/query string?)
(s/def :svc/request (s/keys :req [:svc/query]))
(s/def :svc/result (s/coll-of string? :gen-max 3))
(s/def :svc/error int?)
(s/def :svc/response (s/or :ok (s/keys :req [:svc/result])
                          :err (s/keys :req [:svc/error])))

(s/fdef invoke-service
  :args (s/cat :service any? :request :svc/request)
  :ret :svc/response)

(s/fdef run-query
  :args (s/cat :service any? :query string?)
  :ret (s/or :ok :svc/result :err :svc/error))

그리고 invoke-serviceinstrument로 스텁해 원격 서비스가 호출되지 않게 한 채 run-query의 동작을 테스트하고 싶어요.

(stest/instrument `invoke-service {:stub #{`invoke-service}})
;;=> [user/invoke-service]
(invoke-service nil {:svc/query "test"})
;;=> #:svc{:error -11}
(invoke-service nil {:svc/query "test"})
;;=> #:svc{:result ["kq0H4yv08pLl4QkVH8" "in6gH64gI0ARefv3k9Z5Fi23720gc"]}
(stest/summarize-results (stest/check `run-query))  ;; might take a bit
;;=> {:total 1, :check-passed 1}

첫 번째 호출은 invoke-service를 계측하고 스텁해요. 두 번째·세 번째 호출은 invoke-service 호출이 (서비스에 닿는 대신) 생성된 결과를 돌려주는 걸 보여줘요. 마지막으로 고수준 함수에 check를 사용해, invoke-service가 반환한 생성된 스텁 결과에 기반해 함수가 제대로 동작하는지 테스트할 수 있어요.

마무리

이 가이드에서는 spec과 제너레이터를 설계·사용하기 위한 대부분의 기능을 다뤘어요. 앞으로 업데이트에서 더 고급 제너레이터 기법과 테스팅 도움을 추가할 예정이에요.

더 알아보기