Clojure

spec 指南

開始

spec 函式庫 (API 文件) 指定資料結構,驗證或符合資料結構,並可根據規格產生資料。

若要使用規格,請宣告對 Clojure 1.9.0 或更高版本的相依性

[org.clojure/clojure "1.11.2"]

若要開始使用規格,請在 REPL 中載入 clojure.spec.alpha 名稱空間

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

或在你的名稱空間中包含規格

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

謂詞

每個規格都描述一組允許的值。有許多方法可以建立規格,而且所有方法都可以組成以建立更精密的規格。

任何現有的 Clojure 函式,如果只接受一個引數並傳回真值,就是一個有效的謂詞規格。我們可以使用 conform 來檢查特定資料值是否符合規格

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

conform 函式接受可以是規格的任何東西和資料值。這裡我們傳遞一個謂詞,會隱含地轉換成規格。傳回值是「conformed」。在此,conformed 值與原始值相同 - 我們稍後會看到開始偏離的地方。如果值不符合規格,會傳回特殊值 :clojure.spec.alpha/invalid

如果你不想使用 conformed 值或檢查 :clojure.spec.alpha/invalid,可以使用輔助函式 valid? 來傳回布林值。

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

請再次注意,valid? 會隱含地將謂詞函式轉換成規格。規格函式庫允許你利用你已經擁有的所有函式 - 沒有特殊的謂詞字典。更多範例

(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

集合也可以用作謂詞,用來比對一個或多個文字值

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

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

註冊表

到目前為止,我們一直直接使用規格。不過,規格提供一個集中註冊表,用於全域宣告可重複使用的規格。註冊表將命名空間關鍵字與規格關聯起來。使用命名空間可確保我們可以在函式庫或應用程式中定義可重複使用且不會衝突的規格。

規格使用 s/def 進行註冊。註冊規格的命名空間由你決定(通常是你控制的命名空間)。

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

已註冊的規格識別碼可以在我們到目前為止看到的運算中取代規格定義 - conformvalid?

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

稍後你會看到,已註冊的規格可以在我們組成規格的任何地方使用(而且應該使用)。

規格名稱

規格名稱永遠都是完全限定的關鍵字。一般來說,Clojure 程式碼應該使用足夠獨特的關鍵字命名空間,以避免與其他函式庫提供的規格衝突。如果您正在撰寫供公眾使用的函式庫,規格命名空間應該包含專案名稱、網址或組織。在私人組織中,您可能可以使用較短的名稱 - 重要的是它們足夠獨特以避免衝突。

在本指南中,我們通常會使用較短的限定名稱,以簡潔為例。

一旦規格已新增至註冊表,doc 就知道如何尋找它並列印它

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

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

組合謂詞

組合規格最簡單的方法是使用 andor。讓我們建立一個規格,將多個謂詞組合成一個複合規格,使用 s/and

(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 規格是我們所見的第一個案例,其中涉及驗證有效性期間的選擇。每個選項都註解有一個標籤(在此,在 :name:id 之間),這些標籤給予分支名稱,可用於了解或豐富從 conform 和其他規格函式傳回的資料。

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 來建立規格

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

說明

explain 是規格中的另一個高階運算,可用於報告(至 *out*)值為何不符合規格。讓我們看看說明對我們到目前為止所見的一些不符合範例有何看法。

(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

讓我們更仔細地檢查最後一個範例的輸出。首先請注意,有兩個錯誤正在報告 - 規格將評估所有可能的替代方案,並報告每個路徑上的錯誤。每個錯誤的部分為

  • val - 使用者輸入中不匹配的值

  • spec - 正在評估的規格

  • at - 一個路徑(關鍵字向量),指出規格中發生錯誤的位置 - 路徑中的標籤對應到規格中任何標記的部分(oralt 中的選項、cat 的部分、map 中的鍵等)

  • predicate - 未由 val 滿足的實際謂詞

  • in - 通過巢狀資料 val 到失敗值的鍵路徑。在此範例中,頂層值是失敗值,因此這基本上是一個空路徑,並被省略。

對於第一個報告的錯誤,我們可以看到值 :foo 未在規格 :domain/name-or-id 的路徑 :name 中滿足謂詞 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 中新增的命名空間 map 文字語法。map 可以加上前綴 #:#::(用於自動解析),以指定 map 中所有鍵的預設命名空間。在此範例中,這等同於 {:clojure.spec.alpha/problems …​}

實體 Map

Clojure 程式大量依賴傳遞資料 map。其他函式庫中常見的方法是描述每個實體類型,結合它所包含的鍵和其值的結構。規格並未在實體(map)的範圍內定義屬性(鍵+值)規格,而是為個別屬性指定意義,然後使用集合語意(在鍵上)將它們收集到 map 中。這種方法讓我們可以開始在我們的函式庫和應用程式中,在屬性層級指定(並共用)語意。

例如,大多數 Ring 中介軟體函式會使用未限定鍵修改請求或回應 map。然而,每個中介軟體都可以改用具有已註冊語意的命名空間鍵。然後可以檢查鍵的一致性,建立一個具有更多協作和一致性機會的系統。

規格中的實體 map 是使用 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/person 規格,其中包含必要鍵 :acct/first-name:acct/last-name:acct/email,以及選用鍵 :acct/phone。map 規格從未指定屬性的值規格,只指定哪些屬性是必要的或選用的。

當對 map 檢查一致性時,它會執行兩件事 - 檢查是否包含必要屬性,以及檢查每個已註冊鍵是否具有符合的值。我們稍後會看到選用屬性何時會派上用場。另外請注意,所有屬性都是透過 keys 檢查的,不只是列在 :req:opt 鍵中的屬性。因此,一個單獨的 (s/keys) 是有效的,它會檢查 map 的所有屬性,而不會檢查哪些鍵是必要的或選用的。

(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 - 失敗的規格,在此為 :acct/email-type

  • at - 規格中導致值失敗的路徑

  • predicate - 失敗的謂詞,在此為 (re-matches email-regex %)

許多現有的 Clojure 程式碼並未使用具有命名空間金鑰的映射,因此 keys 也能指定 :req-un:opt-un 以取得必要的和選擇性的非限定金鑰。這些變體會指定用於尋找其規格的命名空間金鑰,但映射只會檢查金鑰的非限定版本。

讓我們考慮一個使用非限定金鑰但根據我們先前註冊的命名空間規格檢查相符性的 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 使用 regex op keys* 為此模式提供特別支援。keys* 具有與 keys 相同的語法和語意,但可以嵌入順序 regex 結構中。

(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 規格可以用於將多個 s/keys 規格合併成一個規格,以結合它們的需求。例如,考慮兩個定義一般動物屬性和一些特定於狗的屬性的 keys 規格。狗實體本身可以描述為這兩個屬性集合的 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 中,一個常見的現象是使用映射作為標記實體,以及一個特殊欄位來表示映射的「類型」,其中類型表示一組潛在的類型,這些類型通常具有共用的屬性。

如前所述,所有類型的屬性都使用儲存在註冊表中的命名空間關鍵字屬性明確指定。跨實體類型共用的屬性會自動獲得共用語義。然而,我們也希望能夠為每個實體類型指定所需的鍵,因此規格提供 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 欄位)的調度函式,並根據該值傳回適當的規格

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

這些方法應該忽略其引數,並傳回指定類型的規格。這裡我們已完整指定兩個可能的事件 - 「搜尋」事件和「錯誤」事件。

最後,我們準備宣告我們的 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.dev.org.tw"})
;=> 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

讓我們花點時間檢查那個最終範例中的說明錯誤輸出。偵測到兩種不同的失敗類型。第一個失敗是事件中缺少必要的 :event/timestamp 鍵。第二個是來自無效的 :search/url 值(數字而不是字串)。我們看到與先前說明錯誤相同的部份

  • in - 資料中導致值失敗的路徑。第一個錯誤中省略此路徑,因為它位於根值,但第二個錯誤中是映射中的鍵。

  • val - 失敗的值,可能是完整的映射或映射中的個別鍵

  • spec - 實際失敗的規格

  • at - 失敗值發生在規格中的路徑

  • predicate - 實際失敗的謂詞

multi-spec 方法允許我們建立一個規格驗證的開放式系統,就像多重方法和協定一樣。稍後只要延伸 event-type 多重方法,就可以新增新的事件類型。

集合

提供了一些輔助程式,用於其他特殊集合案例 - coll-oftuplemap-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,則會使用輸入集合類型。

以下是利用其中一些選項來指定一個包含三個不同數字的向量,並將其設定為集合的範例,以及一些不同類型的無效值的錯誤

(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 值的「點」結構的這種情況下,我們實際上有三種可能的規格可供選擇

  • 正規表示式 - (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

您也可以在 map-of 上使用 coll-of 提供的各種計數相關選項。

序列

有時會使用順序資料編碼額外的結構(通常是新的語法,常在巨集中使用)。spec 提供標準的 正規表示法 運算子來描述順序資料值的結構

  • cat - 預測/模式的串接

  • alt - 選擇替代預測/模式

  • * - 0 個或更多預測/模式

  • + - 1 個或更多預測/模式

  • ? - 0 個或 1 個預測/模式

類似於 orcatalt 都會標記其「部分」 - 這些標記接著會用於符合的值,以識別匹配的內容、報告錯誤等等。

考慮一個由包含數量(數字)和單位(關鍵字)的向量表示的成分。此資料的 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 來指定順序資料中的替代方案。類似於 catalt 要求您標記每個替代方案,但符合的資料是標記和值的向量。

(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 也定義了一個額外的正規表示法運算子 &,它會取得一個正規表示法運算子,並使用一個或多個額外的預測來限制它。這可以用於建立具有額外限制的正規表示法,否則需要自訂預測。例如,考慮只想要匹配具有偶數個字串的序列

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

使用規格進行驗證

現在是退一步思考規格如何用於執行時期資料驗證的好時機。

使用規格的一種方法是明確呼叫 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 來宣告值符合規格。成功時會傳回值,失敗時會擲出宣告錯誤。預設宣告檢查已關閉 - 這可以在 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 並使用具有解構的傳回值來拆解輸入。這對於具有替代選項的複雜輸入特別有用。

我們在此使用上面定義的設定規格進行符合

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

在此,設定呼叫 conform 以產生適合解構設定輸入的資料。結果可能是特殊值 ::s/invalid 或結果的註解形式

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

在成功的情況下,已剖析的輸入會轉換成所需的形狀以進行進一步處理。在錯誤的情況下,我們呼叫 explain-data 來產生錯誤訊息資料。說明資料包含有關哪個表示式不符合、規格中該表示式的路徑,以及它嘗試比對的謂詞的資訊。

函式規格

前一節中的前置和後置條件範例暗示了一個有趣的問題 - 我們如何定義函式或巨集的輸入和輸出規格?

規格使用 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*

第二個 :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 的函數。我們可以使用 fspecadder 宣告函數規格,以取得回傳值

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

巨集

由於巨集是會取得程式碼並產生程式碼的函數,因此也可以像函數一樣進行規格化。然而,一個特別的考量是,您必須記住您接收的是程式碼而非已評估的參數,而且您最常產生的是新的程式碼資料,因此通常無法規格化巨集的 :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

錯誤指示資料結構中的金鑰路徑到無效值、不匹配值、它嘗試匹配的規格部分、該規格中的路徑以及失敗的謂詞。

如果我們有一個函式 deal,它可以將一些紙牌分配給玩家,我們可以指定該函式以驗證 arg 和回傳值都是合適的資料值。我們還可以指定一個 :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))))

產生器

規格的主要設計限制是,所有規格也設計為充當符合規格的範例資料產生器(屬性測試的一項關鍵要求)。

專案設定

規格產生器依賴於 Clojure 屬性測試程式庫 test.check。但是,這個相依性是動態載入的,你可以使用 genexercise 和測試以外的規格部分,而無需宣告 test.check 為執行時期相依性。當你希望使用規格的這些部分(通常在測試期間)時,你需要宣告 test.check 的開發相依性。

在 deps.edn 專案中,建立一個開發別名

{...
 :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 中,開發設定檔相依性會在測試期間包含,但不會發布為相依性或包含在 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 函式可用於取得任何規格的產生器。

一旦你使用 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

所以我們現在可以從一個規格開始,萃取一個產生器,並產生一些資料。所有產生的資料都將符合我們用作產生器的規格。對於具有與原始值不同的符合值的規格(使用 s/or、s/cat、s/alt 等的任何內容),查看一組產生的範例加上符合該範例資料的結果會很有用。

練習

對於這個,我們有 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]])

對於已指定規格的函式,我們也有 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 產生器

我們看過的所有產生器都能正常運作,但有許多情況需要一些額外的協助。一個常見的情況是,當謂詞隱含地假設特定類型的值,但規格未指定它們時

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

在這種情況下,規格無法為 even? 謂詞找到產生器。規格中大多數的原始產生器都對應到常見的類型謂詞(字串、數字、關鍵字等)。

然而,規格透過 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 次以產生通過篩選器的值。這是一個你需要介入並提供自訂產生器的案例。

自訂產生器

建立你自己的產生器讓你能夠更狹隘和/或更明確地說明你想要產生哪些值。或者,自訂產生器可以在使用基本謂詞加上篩選器產生符合值效率較低的情況下使用。規格不信任自訂產生器,它們產生的任何值也將由其關聯規格檢查,以確保它們通過符合性。

有下列三種建立自訂產生器的途徑,依偏好程度遞減排列

  1. 讓 spec 根據謂詞/spec 建立產生器

  2. 從 clojure.spec.gen.alpha 中的工具建立自己的產生器

  3. 使用 test.check 或其他相容於 test.check 的函式庫(例如 test.chuck

最後一個選項需要 test.check 的執行時期相依性,因此強烈建議優先選擇前兩個選項,而非直接使用 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,請使用 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 中同名的函式。您應該參閱 test.check 的文件,以取得所有 clojure.spec.gen.alpha 產生器函式運作方式的更多詳細資料。

在這個案例中,我們希望我們的關鍵字具有開放的名稱,但固定的命名空間。有許多方法可以達成這個目標,但最簡單的方法之一是使用 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 提供了這些情況的輔助程式。

例如,在整數值範圍的情況下(例如,保齡球滾動),使用 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 規範正在儀器化函數上被呼叫,因此為函數的外部使用提供驗證。讓我們為先前規範的 ranged-rand 函數開啟儀器化

(stest/instrument `ranged-rand)

Instrument 採用完全限定的符號,因此我們在此處使用 ` 在目前命名空間的內容中解析它。如果函數被呼叫時所帶的引數不符合 :args 規範,您會看到類似這樣的錯誤

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

錯誤發生在檢查 (< start end) 的第二個引數謂詞中。請注意,:ret:fn 規範不會在儀器化中被檢查,因為實作的驗證應該在測試時發生。

可以使用互補函數 unstrument 關閉儀器化。儀器化可能在開發階段和測試階段都很有用,以找出呼叫程式碼中的錯誤。由於檢查引數規範會造成負擔,因此不建議在生產環境中使用儀器化。

測試

我們前面提到 clojure.spec.test.alpha 提供了自動測試函數的工具。當函數具有規範時,我們可以使用 check 自動產生測試,使用規範檢查函數。

check 會根據函數的 :args 規範產生引數,呼叫函數,並檢查 :ret:fn 規範是否得到滿足。

(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 以影響測試執行,以及透過名稱或路徑覆寫規格部分的產生器選項。

想像一下,我們在 ranged-rand 程式碼中犯了一個錯誤,並交換開始與結束

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

這個損壞的功能仍然會建立隨機整數,只是不在預期的範圍內。我們的 :fn 規格會在檢查變數時偵測到問題

(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 規格中回報一個錯誤。我們可以看到傳遞的引數是 -3 和 0,而回傳值是 -5,這超出預期的範圍。

若要測試命名空間(或多個命名空間)中所有已規範的功能,請使用 enumerate-namespace 來產生命名空間中命名變數的符號集合

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

而且您可以呼叫 stest/check 而無任何引數,以檢查所有已規範的功能。

結合 checkinstrument

雖然 instrument(用於啟用 :args 檢查)和 check(用於產生函數測試)都是有用的工具,但它們可以結合使用,以提供更深入的測試涵蓋範圍。

instrument 採用一些選項來變更已編入工具函數的行為,包括支援替換為備用(較窄)規格、存根函數(透過使用 :ret 規格產生結果)或以備用實作取代函數。

考慮我們有一個呼叫遠端服務的低階函數,以及一個呼叫它的高階函數。

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

我們可以使用下列規格來規範這些函數

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

然後我們想要測試 run-query 的行為,同時使用 instrument 存根 invoke-service,這樣就不會呼叫遠端服務

(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 傳回的產生存根結果正常運作。

總結

在本指南中,我們已涵蓋大部分設計和使用規格和產生器的功能。我們預計在未來的更新中會加入一些更進階的產生器技巧,並協助測試。

原始作者:Alex Miller