规范:部分覆盖地图规范中的生成器

Spec: partially overriding generators in a map spec

假设我已经定义了一个规范,我想从中生成测试数据:

(s/def :customer/id uuid?)
(s/def :customer/given-name string?)
(s/def :customer/surname string?)
(s/def :customer/age pos?)
(s/def ::customer
  (s/keys
    :req-un [:customer/id
             :customer/given-name
             :customer/surname
             :customer/age]))

在生成测试数据时,我想重写 ID 的生成方式以确保它们来自较小的池以鼓励冲突:

(defn customer-generator
  [id-count]
  (gen/let [id-pool (gen/not-empty (gen/vector (s/gen :customer/id) id-count))]
    (assoc (s/gen ::customer) :id (gen/element id-pool))))

有没有一种方法可以通过在我的测试代码中覆盖 :customer/id 生成器然后只使用 (s/gen ::customer) 来简化这个?因此,类似以下内容:

(with-generators [:customer/id (gen/not-empty (gen/vector (s/gen :customer/id) id-count)))]
  (s/gen ::customer))

Clojure 规范使用 test.check internally to generate sample values. Here is how test.check can be overridden. Whenever trying to write unit tests with a "fake" function, with-redefs 是你的朋友:

(ns tst.demo.core
  (:use tupelo.core tupelo.test)
  (:require
    [clojure.test.check.generators :as gen]
    ))

(def  id-gen gen/uuid)
(dotest
  (newline)
  (spyx-pretty (take 3 (gen/sample-seq id-gen)))

  (newline)
  (with-redefs [id-gen (gen/choose 1 5)]
    (spyx-pretty (take 33 (gen/sample-seq id-gen))))
  (newline)
  )

结果:

-----------------------------------
   Clojure 1.10.3    Java 15.0.2
-----------------------------------

Testing tst.demo.core

(take 3 (gen/sample-seq id-gen)) => 
[#uuid "cbfea340-1346-429f-ba68-181e657acba5"
 #uuid "7c119cf7-0842-4dd0-a23d-f95b6a68f808"
 #uuid "ca35cb86-1385-46ad-8fc2-e05cf7a1220a"]

(take 33 (gen/sample-seq id-gen)) => 
[5 4 3 3 2 2 3 1 2 1 4 1 2 2 4 3 5 2 3 5 3 2 3 2 3 5 5 5 5 1 3 2 2]

示例已创建 使用 my favorite template project.


更新

不幸的是,上述技术不适用于 Clojure Spec,因为 (s/def ...) 使用 Spec 定义的全局寄存器,因此不受 with-redefs 的影响。但是,我们可以通过简单地在单元测试命名空间中重新定义所需的规范来克服此定义,例如:

(ns tst.demo.core
  (:use tupelo.core tupelo.test)
  (:require
    [clojure.spec.alpha :as s]
    [clojure.spec.gen.alpha :as gen]
  ))

(s/def :app/id (s/int-in 9 99))
(s/def :app/name string?)
(s/def :app/cust (s/keys :req-un [:app/id :app/name]))

(dotest
  (newline)
  (spyx-pretty (gen/sample (s/gen :app/cust)))

  (newline)
  (s/def :app/id (s/int-in 2 5)) ; overwrite the definition of :app/id for testing
  (spyx-pretty (gen/sample (s/gen :app/cust)))

  (newline))

结果

-----------------------------------
   Clojure 1.10.3    Java 15.0.2
-----------------------------------

Testing tst.demo.core

(gen/sample (s/gen :app/cust)) => 
[{:id 10, :name ""}
 {:id 9, :name "n"}
 {:id 10, :name "fh"}
 {:id 9, :name "aI"}
 {:id 11, :name "8v5F"}
 {:id 10, :name ""}
 {:id 10, :name "7"}
 {:id 10, :name "3m6Wi"}
 {:id 13, :name "OG2Qzfqe"}
 {:id 10, :name ""}]

(gen/sample (s/gen :app/cust)) => 
[{:id 3, :name ""}
 {:id 3, :name ""}
 {:id 2, :name "5e"}
 {:id 3, :name ""}
 {:id 2, :name "y01C"}
 {:id 3, :name "l2"}
 {:id 3, :name "c"}
 {:id 3, :name "pF"}
 {:id 4, :name "0yrxyJ7l"}
 {:id 4, :name "40"}]

所以,它有点难看,但是 :app/id 的重新定义可以解决问题,它只在单元测试运行时生效,不影响主应用程序。

user> (def ^:dynamic *idgen* (s/gen uuid?))
#'user/*idgen*
user> (s/def :customer/id (s/with-gen uuid? (fn [] @#'*idgen*)))
:customer/id
user> (s/def :customer/age pos-int?)
:customer/age
user> (s/def ::customer (s/keys :req-un [:customer/id :customer/age]))
:user/customer
user> (gen/sample (s/gen ::customer))
({:id #uuid "d18896f1-6199-42bf-9be3-3d0652583902", :age 1}
 {:id #uuid "b6209798-4ffa-4e20-9a76-b3a799a31ec6", :age 2}
 {:id #uuid "6f9c6400-8d79-417c-bc62-6b4557f7d162", :age 1}
 {:id #uuid "47b71396-1b5f-4cf4-bd80-edf4792300c8", :age 2}
 {:id #uuid "808692b9-0698-4fb8-a0c5-3918e42e8f37", :age 2}
 {:id #uuid "ba663f0a-7c99-4967-a2df-3ec6cb04f514", :age 1}
 {:id #uuid "8521b611-c38c-4ea9-ae84-35c8a2d2ff2f", :age 4}
 {:id #uuid "c559d48d-4c50-438f-846c-780cdcdf39d5", :age 3}
 {:id #uuid "03c2c114-03a0-4709-b9dc-6d326a17b69d", :age 40}
 {:id #uuid "14715a50-81c5-48e4-bffe-e194631bb64b", :age 4})
user> (binding [*idgen* (let [idpool (gen/sample (s/gen :customer/id) 5)] (gen/elements idpool))] (gen/sample (s/gen ::customer)))
({:id #uuid "3e64131d-e7ad-4450-993d-fa651339df1c", :age 2}
 {:id #uuid "575b2bef-956d-4c42-bdfa-982c7756a33c", :age 1}
 {:id #uuid "575b2bef-956d-4c42-bdfa-982c7756a33c", :age 1}
 {:id #uuid "3e64131d-e7ad-4450-993d-fa651339df1c", :age 1}
 {:id #uuid "1a2eafed-8242-4229-b432-99edb361569d", :age 3}
 {:id #uuid "1a2eafed-8242-4229-b432-99edb361569d", :age 1}
 {:id #uuid "05bd521a-26f9-46e0-8b26-f798e0bf0452", :age 3}
 {:id #uuid "575b2bef-956d-4c42-bdfa-982c7756a33c", :age 19}
 {:id #uuid "31b80714-7ae0-40a0-b932-f7b5f078f2ad", :age 2}
 {:id #uuid "05bd521a-26f9-46e0-8b26-f798e0bf0452", :age 5})
user>  

比你想要的有点笨拙,但也许这就足够了。

您最好使用 binding 而不是 with-redefs,因为 binding 修改线程本地绑定,而 with-redefs 更改根绑定。

由于这是为了生成错误的测试数据,我会考虑完全避免使用动态变量和 binding,而只使用仅对测试环境本地的不同规范。

正式地,您可以通过将覆盖映射传递给 s/gen 来覆盖规范的生成器(有关更多详细信息,请参阅文档字符串):

(s/def :customer/id uuid?)
(s/def :customer/given-name string?)
(s/def :customer/surname string?)
(s/def :customer/age nat-int?)
(s/def ::customer
  (s/keys
    :req-un [:customer/id
             :customer/given-name
             :customer/surname
             :customer/age]))

(def fixed-customer-id (java.util.UUID/randomUUID))
fixed-customer-id
;=> #uuid "c73ff5ea-8702-4066-a31d-bc4cc7015811"
(gen/generate (s/gen ::customer {:customer/id #(s/gen #{fixed-customer-id})}))
;=> {:id #uuid "c73ff5ea-8702-4066-a31d-bc4cc7015811",
;    :given-name "1042IKQhd",
;    :surname "Uw0AzJzj",
;    :age 104}

或者,有一个名为 genman 的库,是我之前开发的 :) 使用它,你也可以写成:

(require '[genman.core :as genman :refer [defgenerator]])

(def fixed-customer-id (java.util.UUID/randomUUID))

(genman/with-gen-group :test
  (defgenerator :customer/id
    (s/gen #{fixed-customer-id})))

(genman/with-gen-group :test
  (gen/generate (genman/gen ::customer)))