Clojure

spec 指南

入门

spec 库 (API 文档) 指定数据结构,验证或符合数据结构,并且可以根据 spec 生成数据。

要使用 spec,请声明对 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 代码应使用足够唯一的关键字命名空间,以便它们不会与其他库提供的规范冲突。如果您正在编写用于公共用途的库,则规范命名空间应包括项目名称、URL 或组织。在私有组织中,您可能可以使用较短的名称 - 重要的是要确保它们足够唯一以避免冲突。

在此指南中,为简洁起见,我们将经常使用较短的限定名称。

一旦将一个规范添加到注册表中,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* 报告)值不符合规范的原因。让我们看看 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

让我们仔细检查最终示例的输出。首先注意,有两个错误正在报告——规范将评估所有可能的替代项,并在每条路径上报告错误。每个错误的部分是

  • val——与规范不匹配的用户输入中的值

  • 规范——正在评估的规范

  • at——一条路径(一个关键字向量),指示错误发生在规范中的位置——路径中的标记对应于规范中的任何标记部分(oralt 中的替代项、cat 的部分、映射中的键等)

  • 谓词——实际上不满足 val 的谓词

  • in——穿过嵌套数据 val 的键路径到失败值。在此示例中,顶级值是失败值,因此这基本上是一个空路径,并且被省略。

对于第一个报告的错误,我们可以看到值 :foo 没有满足谓词 string?,在规范 :domain/name-or-id 中的路径 :name 中。第二个报告的错误类似,但失败在路径 :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 程序很大程度上依赖于传递数据映射。其他库中的一种常见方法是对每种实体类型进行描述,既要包含其密钥还要包含其值的结构。规范并非在实体(映射)的作用域中定义属性(键加值)规范,而是为各个属性分配含义,然后使用集合语义(在键上)将其收集到映射中。这种方法使我们能够跨库和应用程序在属性级别开始分配(和共享)语义。

例如,大多数 Ring 中间件函数都会使用不合格键修改请求或响应映射。但是,每个中间件都可以改为对那些键使用具有注册语义的命名空间键。然后,可以针对键合规性进行检查,从而创建具有更大协作和一致性机会的系统。

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 规范。映射规范从不指定属性的值规范,仅指定哪些属性是必需的,哪些是可选的。

当对映射进行合规性检查时,它会做两件事 - 检查是否包括必需属性,以及检查每个注册键是否具有符合的值。我们稍后会看到可选属性的用处。还需要注意的是,将通过 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

我们花点时间来检查对最终例子的解释错误输出

  • in - 数据中到故障值(此处为个体实例中的键)的路径

  • val - 故障值,此处为 "n/a"

  • spec - 故障规范,此处为 :acct/email-type

  • at - 故障值所在规范中的路径

  • predicate - 故障谓词,此处为 (re-matches email-regex %)

许多现有的 Clojure 代码不使用具有命名空间键的映射,因此 keys 还可指定 :req-un:opt-un 以表示必需和可选的不合格键。这些变体指定用于查找其规范的命名空间键,但映射仅检查键的不合格版本。

我们考虑使用不合格键的个体映射,但针对我们先前注册的命名空间规范检查合规性

(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

不合格键还可用于验证记录属性

(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 中有种常见情况,使用“关键词参数”,其中关键字键和值以连续的数据结构形式作为选项传递。Spec 通过正则表达式 op 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 中,该单个 spec 会组合它们的要求。例如,考虑两个 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 中有种常见情况,使用映射作为标记实体以及一个特殊字段,该字段指示映射的“类型”,其中类型指示一组潜在公开的类型,通常这些类型之间具有共用属性。

正如之前讨论的那样,使用以命名空间关键字存储在注册表中的属性,可以很好地指定所有类型的属性。在实体类型之间共用的属性会自动获得共用的语义。但是,我们还希望能够为每种实体类型指定必需的键,并且 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。这里,我们完全指定了两个可能的事件 - “search”事件和“error”事件。

最后,我们准备声明我们的 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

让我们花点时间检查该最终示例中的说明错误输出。检测到两种不同类型的失败。第一个失败是由于事件中缺少必需的 :event/timestamp 键。第二个失败来自于无效的 :search/url 值(一个数字,而不是字符串)。我们看到与之前的说明错误相同的部分

  • in - 数据中通往失败值的路径。这在第一个错误中被省略,因为它在根值处,但它在第二个错误中是映射中的键。

  • val - 失败的值,即完整映射或映射中的单个键

  • spec - 实际失败的 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 个谓词/模式

or 类似,catalt 都标记它们的“部分”——这些标记随后用于一致的值中以识别匹配内容、报告错误以及其他操作。

考虑一个成分,它由包含数量(数字)和单位(关键词)的向量表示。此数据的规范使用 cat 来按正确顺序指定正确的组件。与谓词类似,在传递到 conformvalid? 等函数时,正则表达式运算符会隐式转换为规范。

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

规范还额外定义了一个正则表达式运算符,&,它获取一个正则表达式运算符,并用一个或多个其他谓词限定它。这可用于创建正则表达式,其中附加了其他约束,而使用其他方法需要自定义谓词。例如,考虑仅匹配字符串数目为偶数的序列

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

如果删除规范,此规范反而会匹配诸如 [: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 "[email protected]"})
;;=> "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 并使用返回值和解构分离出输入。这对于具有备选方案的复杂输入尤为有用。

这里我们根据上面定义的 config 规范执行 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"])

此处,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*)来描述它们。

第二个 :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,我们可以对该函数制定规范来验证参数值和返回值是否都是合适的数据值。我们还可以指定一个 :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 项目中,创建 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 函数可用于获取任何规范的生成器。

使用 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. 让规范根据谓词/规范创建一个生成器

  2. 从 clojure.spec.gen.alpha 中的工具创建您自己的生成器

  3. 使用与 test.check 兼容的其他 test.check 兼容的库(比如 test.chuck

最后一个选项需要对 test.check 进行运行时依赖,因此强烈建议优先使用前两个选项,而不是直接使用 test.check。

首先考虑使用谓词来指定特定名称空间中的关键字的规范

(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

开始针对此规范生成值的简单方法是让规范从固定的选项集中创建一个生成器。集合是一个有效的谓词规范,因此,我们可以创建一个集合并要求其生成器

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

若要使用此自定义生成器重新定义规范,请使用 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(以及获取自定义生成器的其他地方)获取一个无参数函数,该函数返回生成器,以便延迟实现该函数。

此方法的缺点之一是我们错失了属性测试真正擅长的内容:自动在宽广的搜索空间中生成数据以发现意外问题。

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

范围规范和生成器

在多个情况下,在一个范围内对值进行规范(和生成)很有用,并且规范为这些情况提供了帮助。

例如,在整数值范围(例如,保龄球滚球)的情况下,使用 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)

工具采用限定符号,因此我们在此处使用 `` 在当前名称空间的上下文中解析它。如果函数被带有不符合 :args 规范参数调出,你将会看到如下错误

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

错误出现在第二个 args 断言中,该断言检查 (< 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 规范在检查 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 规范中的错误。我们可以看到传递的参数为 -3 和 0,返回值为 -5, 超出了预期范围。

若要测试命名空间(或多个命名空间)中的所有指定函数,请使用 enumerate-namespace 来生成命名空间中 var 的名称符号集

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

然后,我们希望使用 instrument 抑制 invoke-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。第二个和第三个调用演示了现在对 invoke-service 的调用返回的是生成的结果(而不是点击服务)。最后,我们可以对高级函数使用 check 来测试它是否根据从 invoke-service 返回的生成抑制结果而正常运行。

总结

在本指南中,我们介绍了用于设计和使用规范和生成器的大多数功能。我们希望在未来的更新中添加一些更高级的生成器技巧和测试方面的帮助。

最初作者:Alex Miller