Clojure

spec ガイド

はじめに

specライブラリ(spec) (APIドキュメント) は、データの構造を指定し、それを検証または適合させ、specに基づいてデータを生成します。

specを使用するには、Clojure 1.9.0以降への依存関係を宣言します。

[org.clojure/clojure "1.11.2"]

specを使い始めるには、REPLで`clojure.spec.alpha`名前空間をrequireします。

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

または、名前空間にspecを含めます。

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

述語

各specは、許可される値の集合を記述します。specを構築するにはいくつかの方法があり、それらのすべてを組み合わせて、より高度なspecを構築できます。

単一の引数を受け取り、真の値を返す既存のClojure関数は、すべて有効な述語specです。特定のデータ値がspecに適合するかどうかは、conformを使用して確認できます。

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

conform関数は、specになりうるものとデータ値を受け取ります。ここでは、暗黙的にspecに変換される述語を渡しています。戻り値は「適合」です。ここでは、適合した値は元の値と同じです。後で、それがどのように変化し始めるかを見ていきます。値がspecに適合しない場合、特殊値:clojure.spec.alpha/invalidが返されます。

適合した値を使用しない場合、または:clojure.spec.alpha/invalidをチェックしない場合は、代わりにヘルパーvalid?を使用してブール値を返すことができます。

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

valid?も述語関数を暗黙的にspecに変換することに注意してください。specライブラリでは、既に持っているすべての関数を活用できます。述語の特別な辞書はありません。いくつかの追加例

(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

集合も、1つ以上のリテラル値に一致する述語として使用できます。

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

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

レジストリ

これまで、specを直接使用してきました。しかし、specは、再利用可能なspecをグローバルに宣言するためのセントラルレジストリを提供します。レジストリは、名前空間付きキーワードを仕様に関連付けます。名前空間の使用により、ライブラリやアプリケーション間で再利用可能な、競合しないspecを定義できます。

specはs/defを使用して登録されます。仕様を意味のある名前空間(通常は自分が制御する名前空間)に登録するかどうかは、あなた次第です。

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

登録されたspec識別子は、これまで見てきた操作(conformvalid?)では、spec定義の代わりに使用できます。

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

後で、登録されたspecは、specを合成する場所ならどこでも(そしてそうするべきです)使用できることがわかります。

Specの名前

Specの名前は常に完全修飾キーワードです。一般的に、Clojureコードは、他のライブラリによって提供されるspecと競合しないように、十分に一意なキーワード名前空間を使用する必要があります。公開用ライブラリを作成する場合は、specの名前空間には、プロジェクト名、URL、または組織を含める必要があります。プライベート組織内では、より短い名前を使用できる場合があります。重要なのは、競合を避けるために十分に一意であることです。

このガイドでは、例を簡潔にするために、多くの場合、より短い修飾名を使用します。

specがレジストリに追加されると、`doc`はそれを探し出して印刷する方法も知っています。

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

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

述語の合成

specを合成する最も簡単な方法は、andorを使用することです。いくつかの述語をs/andで複合specに組み合わせる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を使用して、2つの代替案を指定することもできます。

(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が適合すると、タグ名と適合した値を含むベクトルが返されます。

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

インスタンスの型をチェックする多くの述語は、有効な値としてnilを許可しません(string?number?keyword?など)。nilを有効な値として含めるには、提供されている関数nilableを使用してspecを作成します。

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

説明

explainは、specにおける別の高レベル操作であり、値がspecに適合しない理由を(*out*に)報告するために使用できます。これまで見てきたいくつかの非適合な例について、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

最後の例の出力について詳しく見てみましょう。まず、2つのエラーが報告されていることに注意してください。specはすべての可能な代替案を評価し、すべてのパスでエラーを報告します。各エラーの部分は次のとおりです。

  • val - 一致しないユーザー入力の値

  • spec - 評価されていたspec

  • at - エラーが発生したspec内の場所を示すパス(キーワードのベクトル)。パスのタグは、spec内のタグ付き部分(orまたはaltの代替案、catの部分、マップ内のキーなど)に対応します。

  • predicate - valによって満たされなかった実際の述語

  • in - 失敗した値へのネストされたデータvalを通るキーパス。この例では、トップレベルの値が失敗しているため、これは本質的に空のパスであり、省略されます。

最初の報告されたエラーでは、値:fooが、spec :domain/name-or-idのパス:nameで述語string?を満たしていないことがわかります。2番目の報告されたエラーは似ていますが、代わりに: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で追加された名前空間マップリテラル構文も示しています。マップは、#:または#::(自動解決用)をプレフィックスとして使用して、マップ内のすべてのキーのデフォルトの名前空間を指定できます。この例では、これは{:clojure.spec.alpha/problems …​}と同等です。

エンティティマップ

Clojureプログラムは、データのマップをやり取りすることに大きく依存しています。他のライブラリにおける一般的なアプローチは、含まれるキーとそれらの値の構造の両方を含めて、各エンティティタイプを記述することです。属性(キー+値)の仕様をエンティティ(マップ)のスコープ内で定義するのではなく、specは個々の属性に意味を割り当て、次に集合意味論(キーについて)を使用してそれらをマップに収集します。このアプローチにより、ライブラリやアプリケーション全体で属性レベルで意味の割り当て(と共有)を開始できます。

たとえば、ほとんどのRingミドルウェア関数は、修飾されていないキーを使用してリクエストまたはレスポンスマップを変更します。しかし、各ミドルウェアは、代わりに、それらのキーの登録された意味を持つ名前空間付きキーを使用できます。次に、キーの適合性をチェックして、コラボレーションと一貫性の機会が増えるシステムを作成できます。

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を決して指定せず、どの属性が必須でどの属性がオプションであるかを指定するだけです。

マップで適合性がチェックされると、2つのことを行います。必須属性が含まれていることを確認し、登録されたすべてのキーに適合する値があることを確認します。後で、オプション属性がどのように役立つのかを見ていきます。また、すべての属性がkeysによってチェックされ、:reqキーと:optキーにリストされているものだけではないことに注意してください。したがって、ベアリ(s/keys)は有効であり、どのキーが必須かオプションかをチェックせずに、マップのすべての属性をチェックします。

(s/valid? :acct/person
  {:acct/first-name "Bugs"
   :acct/last-name "Bunny"
   :acct/email "bugs@example.com"})
;;=> 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 - 失敗した述語。ここでは(re-matches email-regex %)

既存のClojureコードの多くは名前空間付きキーを持つマップを使用していないため、keysは必須の修飾されていないキーとオプションの修飾されていないキーに対して:req-un:opt-unを指定することもできます。これらのバリアントは、それらの仕様を見つけるために使用される名前空間付きキーを指定しますが、マップはキーの修飾されていないバージョンのみをチェックします。

修飾されていないキーを使用するが、前に登録した名前空間付き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 "bugs@example.com"})
;;=> {:first-name "Bugs", :last-name "Bunny", :email "bugs@example.com"}

(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

修飾されていないキーは、レコード属性の検証にも使用できます。

(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" "bugs@example.com" nil))
;;=> #user.Person{:first-name "Bugs", :last-name "Bunny",
;;=>              :email "bugs@example.com", :phone nil}

Clojureでよく見られるのは、キーワードキーと値がオプションとしてシーケンシャルデータ構造で渡される「キーワード引数」の使用です。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を、それらの要件を組み合わせた単一のspecに組み合わせることができます。たとえば、共通の動物属性と犬固有の属性を定義する2つのkeys specを考えてみましょう。犬のエンティティ自体は、それら2つの属性セットの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

マルチspec

Clojureでは、マップをタグ付きエンティティとして使用し、マップの「種類」を示す特別なフィールドを使用することがよくあります。ここで、種類は潜在的に開かれた種類の集合を示し、多くの場合、種類間で属性が共有されます。

前述のように、すべての種類に対する属性は、名前空間付きキーワードによってレジストリに格納された属性を使用して適切に指定されています。エンティティの種類間で共有される属性は、自動的に共有されたセマンティクスを取得します。しかし、エンティティの種類ごとに必要なキーを指定することも必要です。そのため、specはmulti-specを提供します。これはマルチメソッドを利用して、種類タグに基づいて開かれたエンティティの種類の集合の仕様を提供します。

たとえば、いくつかの共通のフィールドを共有するが、種類固有の形状も持つイベントオブジェクトを受け取る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フィールド)を選択するためのディスパッチ関数に定義するマルチメソッドが必要であり、値に基づいて適切なspecを返します。

(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を返す必要があります。ここでは、「検索」イベントと「エラー」イベントの2つの可能なイベントを完全に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.dokyumento.jp"})
;=> 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

最後の例でのエラー説明出力を調べてみましょう。検出された失敗には2種類の異なるものがあります。最初の失敗は、イベントに必要な:event/timestampキーがないことによるものです。2番目は、無効な:search/url値(文字列ではなく数値)によるものです。以前のエラー説明と同じ部分が見られます。

  • in - 失敗した値へのデータ内のパス。これは、ルート値にあるため最初のエラーでは省略されますが、2番目のエラーではマップ内のキーです。

  • val - 失敗した値。マップ全体またはマップ内の個々のキーのいずれかです。

  • spec - 実際に失敗したspec

  • at - 失敗した値が発生したspec内のパス

  • predicate - 実際に失敗した述語

multi-specアプローチにより、マルチメソッドやプロトコルと同様に、spec検証のための**オープン**システムを作成できます。新しいイベントの種類は、event-typeマルチメソッドを拡張するだけで後で追加できます。

コレクション

他の特殊なコレクションの場合には、いくつかのヘルパーが提供されています - coll-oftuple、およびmap-of

任意のサイズの同種のコレクションの特殊なケースでは、述語を満たす要素のコレクションを指定するためにcoll-ofを使用できます。

(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 - 入力コレクションが満たす必要がある述語(例:vector?

  • :count - 期待される正確なカウントを指定します。

  • :min-count:max-count - コレクションが(<= min-count count max-count)を持つことをチェックします。

  • :distinct - すべての要素が異なることをチェックします。

  • :into - 出力された適合値の []、()、{}、または #{} のいずれか。:intoが指定されていない場合、入力コレクションの種類が使用されます。

以下は、これらのオプションの一部を使用して、セットとして適合された3つの異なる数値を含むベクトルを指定する例と、さまざまな種類の無効な値に対するエラーの一部を示しています。

(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はどちらも要素すべてを適合させるため、大規模なコレクションには適さない場合があります。その場合は、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値を持つ「点」構造のこのケースでは、実際には3つの可能なspecを選択できました。

  • 正規表現 - (s/cat :x double? :y double? :z double?)

    • ネストされた構造の照合を許可します(ここでは不要)

    • catタグに基づいて名前付きキーを持つマップに適合します。

  • コレクション - (s/coll-of double?)

    • 任意のサイズの同種コレクション向けに設計されています。

    • 値のベクトルに適合します。

  • タプル - (s/tuple double? double? double?)

    • 既知の位置「フィールド」を持つ固定サイズ向けに設計されています。

    • 値のベクトルに適合します。

この例では、coll-ofは他の(無効な)値([1.0][1.0 2.0 3.0 4.0]など)にも一致するため、適切な選択ではありません。固定フィールドが必要です。ここでは、正規表現とタプルの選択は、ある程度好みの問題であり、タグ付き戻り値またはエラー出力のどちらがどちらでより優れているかによって決まる可能性があります。

keysによる情報マップのサポートに加えて、specは同種のキーと値の述語を持つマップに対して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-keys trueを渡します。

coll-ofで使用できるさまざまなカウント関連のオプションをmap-ofでも使用できます。

シーケンス

シーケンシャルデータを使用して追加の構造(通常は新しい構文、多くの場合マクロで使用)をエンコードすることがあります。specは、シーケンシャルデータ値の構造を記述するために、標準の正規表現演算子を提供します。

  • cat - 述語/パターンの連結

  • alt - 代替述語/パターンの選択

  • * - 述語/パターンを0個以上

  • + - 述語/パターンを1個以上

  • ? - 述語/パターンを0個または1個

orと同様に、cataltは「部分」にタグ付けします。これらのタグは、適合した値を識別し、エラーを報告するなどするために、適合した値で使用されます。

数量(数値)と単位(キーワード)を含むベクトルで表される材料を考えてみましょう。このデータのspecは、catを使用して正しいコンポーネントを正しい順序で指定します。述語と同様に、正規表現演算子は、conformvalid?などの関数に渡されると、暗黙的にspecに変換されます。

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

データは、タグをキーとして持つマップとして適合されます。explainを使用して、非適合データを確認できます。

;; 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は各代替案にタグ付けする必要がありますが、適合したデータはタグと値のベクトルです。

(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"]}]

仕様の説明が必要な場合は、describeを使用して取得します。既に定義したいくつかの仕様で試してみましょう。

(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は、正規表現演算子&も定義しています。これは、正規表現演算子を受け取り、1つ以上の追加の述語で制約します。これは、カスタム述語を必要とするような追加の制約を持つ正規表現を作成するために使用できます。たとえば、偶数の文字列を持つシーケンスのみを一致させたいとします。

(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への明示的な呼び出しを使用する必要があります。たとえば、[: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を使用する1つの方法は、valid?を明示的に呼び出して、関数に渡された入力データを検証することです。たとえば、defnに組み込まれている既存の前条件と後条件のサポートを使用できます。

(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 "bugs@example.com"})
;;=> "Bugs Bunny"

有効な:acct/personデータではないもので関数が呼び出されると、前条件が失敗します。同様に、コードにバグがあり、出力が文字列ではない場合、後条件が失敗します。

別のオプションとして、コード内でs/assertを使用して、値がspecを満たすことをアサートします。成功すると値が返され、失敗するとアサーションエラーがスローされます。デフォルトでは、アサーションチェックは無効になっています。これは、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を呼び出して、戻り値をデストラクチャリングして入力を取り出すことです。これは、代替オプションを持つ複雑な入力に特に役立ちます。

ここで、上記で定義されたconfig仕様を使用して適合させます。

(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"])

ここでは、configureがconformを呼び出して、config入力をデストラクチャリングするために適切なデータを作成します。結果は、特別な::s/invalid値か、結果の注釈付き形式のいずれかです。

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

成功した場合、解析された入力は、さらなる処理のために必要な形状に変換されます。エラーが発生した場合、エラーメッセージデータを生成するためにexplain-data関数を呼び出します。explainデータには、どの式が適合しなかったか、仕様におけるその式のパス、および一致させようとした述語に関する情報が含まれています。

関数仕様

前のセクションの前条件と後条件の例は、興味深い疑問を提起しています。関数またはマクロの入力と出力の仕様をどのように定義するのでしょうか?

Specは、fdefを使用してこれを明示的にサポートしています。これは、関数に対する仕様(引数と/または戻り値の仕様、そしてオプションで引数と戻り値の関係を指定できる関数)を定義します。

範囲内の乱数を生成するranged-rand関数について考えてみましょう。

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

次に、その関数に対する仕様を記述できます。

(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))))

この関数仕様は、いくつかの機能を示しています。まず、:argsは、関数引数を記述する複合仕様です。この仕様は、まるで(apply fn (arg-list))に渡されたかのように、リスト内の引数で呼び出されます。引数はシーケンシャルであり、引数は位置指定フィールドであるため、ほとんどの場合、catalt、または*などの正規表現演算子を使用して記述されます。

2番目の:args述語は、最初の述語の適合結果を入力として受け取り、start < endを検証します。:ret仕様は、戻り値も整数であることを示しています。最後に、:fn仕様は、戻り値がstart >=であり、end <であることをチェックします。

関数の仕様が作成されると、その関数の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)))

後で、開発とテストに関数仕様をどのように使用できるかを確認します。

高階関数

高階関数はClojureで一般的であり、Specはfspecを提供してそれらの仕様をサポートします。

たとえば、adder関数について考えてみましょう。

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

adderはxを加算する関数を返します。戻り値にfspecを使用して、adderの関数仕様を宣言できます。

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

:ret仕様はfspecを使用して、戻り値の関数が数値を受け取り、数値を返すことを宣言します。さらに興味深いことに、:fn仕様では、:args(xがわかっている場所)と、adderから返された関数を呼び出して得られる結果(つまり、0を加算するとxが返されること)を関連付ける一般的なプロパティを記述できます。

マクロ

マクロはコードを受け取ってコードを生成する関数であるため、関数のように仕様を記述することもできます。ただし、1つの特別な考慮事項として、評価された引数ではなくデータとしてコードを受け取っていること、そして最も一般的にはデータとして新しいコードを生成していることを心に留めておく必要があります。そのため、マクロの:ret値を仕様することは、(単なるコードであるため)多くの場合役に立ちません。

たとえば、clojure.core/declareマクロの仕様を次のように記述できます。

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

Clojureのマクロ展開器は、マクロ展開時(実行時ではない!)にマクロに登録された:args仕様を検索して適合させます。エラーが検出された場合、explainが呼び出されてエラーが説明されます。

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

マクロは常にマクロ展開時にチェックされるため、マクロ仕様に対してinstrument関数を呼び出す必要はありません。

カードゲーム

カードゲームをモデル化するより大きな仕様セットを以下に示します。

(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内のパス、および失敗した述語を示しています。

プレーヤーにいくつかのカードを配るdeal関数がある場合、その関数の仕様を記述して、引数と戻り値の両方が適切なデータ値であることを検証できます。また、:fn仕様を指定して、配る前のゲーム内のカードの数と、配った後のカードの数が等しいことを検証することもできます。

(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))))

ジェネレーター

Specの重要な設計制約の1つは、すべてのSpecが、Specに適合するサンプルデータのジェネレーターとしても機能することです(プロパティベースのテストの重要な要件)。

プロジェクト設定

Specジェネレーターは、Clojureのプロパティテストライブラリtest.checkに依存しています。ただし、この依存関係は動的にロードされるため、genexercise、およびテスト以外のSpecの部分は、test.checkを実行時依存関係として宣言せずに使用できます。Specのこれらの部分(通常はテスト中に)を使用する場合は、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では、依存関係をテストスコープ依存関係として宣言します。

<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に適合します。元の値とは異なる適合値を持つSpec(s/or、s/cat、s/altなどを使用するもの)の場合、生成されたサンプルのセットと、そのサンプルデータを適合させた結果を確認すると役立つ場合があります。

演習

これには、Specの生成値と適合値のペアを返すexerciseがあります。exerciseはデフォルトで10個のサンプルを生成しますが(sampleと同じ)、両方の関数に、生成するサンプルの数を示す数値を渡すことができます。

(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された関数の場合、サンプル引数を生成し、Specされた関数を呼び出し、引数と戻り値を返すexercise-fnもあります。

(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ジェネレーターの使用

これまで見てきたジェネレーターはすべて正常に機能しましたが、追加のヘルプが必要になるケースがいくつかあります。一般的なケースの1つは、述語が特定の型の値を暗黙的に前提としているが、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?述語のジェネレーターを見つけられませんでした。Specのプリミティブジェネレーターのほとんどは、一般的な型述語(文字列、数値、キーワードなど)にマップされています。

ただし、Specはandを介してこのケースをサポートするように設計されています。最初の述語はジェネレーターを決定し、後続のブランチは、生成された値に述語を適用することで(test.checkのsuch-thatを使用して)フィルターとして機能します。

andとマップされたジェネレーターを持つ述語を使用して述語を変更すると、even?を生成された値のフィルターとして使用できます。

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

多くの述語を使用して、生成された値をさらに絞り込むことができます。たとえば、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はエラーをスローします。たとえば、「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回しか試行しません。これは、カスタムジェネレーターを提供する必要があるケースです。

カスタムジェネレーター

独自のジェネレーターを構築すると、生成する値をより狭くしたり、より明確にしたりできます。あるいは、カスタムジェネレーターは、基本述語とフィルタリングを使用するよりも効率的に適合値を生成できる場合に使用できます。Specはカスタムジェネレーターを信頼せず、生成された値はすべて関連付けられたSpecによってチェックされ、適合に合格することが保証されます。

カスタムジェネレーターを構築する方法は3つあります(優先度の高い順)。

  1. 述語/Specに基づいてSpecにジェネレーターを作成させる

  2. clojure.spec.gen.alphaのツールから独自のジェネレーターを作成する

  3. test.checkまたはその他のtest.check互換ライブラリ(test.chuckなど)を使用する

最後のオプションでは、test.checkの実行時依存関係が必要となるため、最初の2つのオプションの方がtest.checkを直接使用するよりも強く推奨されます。

まず、特定の名前空間からのキーワードを指定する述語を持つ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にジェネレーターを作成させることです。セットは有効な述語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を再定義するには、Specと置換ジェネレーターを受け取るwith-genを使用します。

(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(およびカスタムジェネレーターを受け取るその他の場所)は、ジェネレーターを返す引数なしの関数を受け取ることに注意してください。これにより、遅延的に実現できます。

このアプローチの欠点の1つは、プロパティテストの真価である、予期しない問題を見つけるために広範な検索空間全体でデータを自動的に生成できないことです。

clojure.spec.gen.alpha名前空間には、ジェネレーター「プリミティブ」と、それらをより複雑なジェネレーターに組み合わせるための「コンビネーター」の多数の関数が含まれています。

clojure.spec.gen.alpha名前空間の関数のほとんどは、test.checkで同名の関数を動的にロードするラッパーにすぎません。clojure.spec.gen.alphaジェネレーター関数の動作の詳細については、test.checkのドキュメントを参照してください。

この場合、キーワードの名前はオープンにする必要がありますが、名前空間は固定する必要があります。これを実現する方法はたくさんありますが、最も簡単な方法の1つは、生成された文字列に基づいてキーワードを作成するために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にはこれらのケースに対応するヘルパーが用意されています。

例えば、整数値の範囲(ボーリングのスコアなど)の場合は、int-in を使用して範囲を指定します(上限は含まれません)。

(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には、瞬間の範囲のための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 は倍精度浮動小数点数範囲をサポートしており、NaN(非数)、Infinity-Infinityなどの特殊な倍精度浮動小数点値をチェックするための特別なオプションも用意されています。

(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.alphaはclojure.test.check.generatorsの大きなサブセットですが、すべてが含まれているわけではないことに注意してください。

インストルメンテーションとテスト

specは、clojure.spec.test.alpha名前空間で、開発およびテスト機能を提供します。これは以下のようにインクルードできます。

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

インストルメンテーション

インストルメンテーションは、インストルメント化された関数で:args specが呼び出されていることを検証し、関数の外部からの使用を検証します。以前specで指定したranged-rand関数に対してインストルメンテーションを有効にしましょう。

(stest/instrument `ranged-rand)

インストルメント化は完全修飾シンボルを受け取るため、ここでは現在の名前空間で解決するために`を使用します。関数が: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)をチェックする2番目の引数の述語で発生します。インストルメンテーションでは:ret:fnのspecはチェックされないことに注意してください。実装の検証はテスト時に実行する必要があります。

インストルメンテーションは、補完的な関数unstrumentを使用してオフにすることができます。インストルメンテーションは、開発時とテスト時の両方で、呼び出し側のコードのエラーを発見するために役立ちます。引数の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に微妙なバグが含まれていることに気付くでしょう。開始と終了の差が非常に大きい場合(Long/MAX_VALUEで表現できる範囲を超える場合)、ranged-randはIntegerOverflowExceptionを発生させます。checkを複数回実行すると、最終的にこのケースが発生します。

checkは、テスト実行に影響を与えるtest.checkに渡すことができる多くのオプションと、名前またはパスによってspecの一部に対するジェネレータを上書きするオプションも提供します。

代わりに、ranged-randコードでエラーが発生し、開始と終了が入れ替わったとします。

(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は、代替の(より狭い)specの交換、関数のスタブ化(: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を使用して指定できます。

(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))

そして、リモートサービスが呼び出されないようにinstrumentinvoke-serviceをスタブ化しながら、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をインストルメント化してスタブ化します。2番目と3番目の呼び出しは、invoke-serviceへの呼び出しが、サービスにアクセスするのではなく、生成された結果を返すようになったことを示しています。最後に、高レベルの関数に対してcheckを使用して、invoke-serviceから返された生成されたスタブ結果に基づいて正しく動作することをテストできます。

まとめ

このガイドでは、specとジェネレータの設計と使用に関するほとんどの機能について説明しました。今後のアップデートで、より高度なジェネレータ技術とテストに関するヘルプを追加する予定です。

原著者:Alex Miller