Clojure

test.check

简介

test.check 是一个用于 Clojure 的基于属性的测试库,灵感来自 QuickCheck

本指南基于版本 0.10.0,将简要介绍使用 test.check 示例的基于属性的测试,然后介绍 API 各个部分的基本用法。

基于属性的测试

基于属性的测试通常与“基于示例的测试”形成对比,后者是通过枚举特定输入和预期输出(即“示例”)来测试函数的测试。本指南以测试纯函数为基础编写,但对于测试不太纯净的系统,您可以想象一个包装测试的函数,该函数使用参数设置系统的上下文,运行系统,然后查询环境以衡量效果,并返回这些查询的结果。

相比之下,基于属性的测试描述了对所有有效输入都应为真的属性。基于属性的测试包括生成有效输入的方法(“生成器”)以及一个函数,该函数采用生成的输入并将其与被测函数结合以确定该属性对于该特定输入是否成立。

一个经典的第一个属性示例是测试 sort 函数,方法是检查它是否幂等。在 test.check 中,可以这样编写

(require '[clojure.test.check :as tc])
(require '[clojure.test.check.generators :as gen])
(require '[clojure.test.check.properties :as prop])

(def sort-idempotent-prop
  (prop/for-all [v (gen/vector gen/int)]
    (= (sort v) (sort (sort v)))))

(tc/quick-check 100 sort-idempotent-prop)
;; => {:result true,
;; =>  :pass? true,
;; =>  :num-tests 100,
;; =>  :time-elapsed-ms 28,
;; =>  :seed 1528580707376}

这里 (gen/vector gen/int) 表达式是 sort 函数输入的生成器;它指定输入是一个整数向量。实际上,sort 可以接受任何兼容的 Comparable 对象的集合;生成器的简单性和它描述实际输入空间的完整性之间通常存在权衡。

名称 v 绑定到一个特定的生成整数向量,并且 prop/for-all 主体中的表达式确定试验是通过还是失败。

tc/quick-check 调用“运行属性”100 次,这意味着它生成一百个整数向量并为每个向量评估 (= (sort v) (sort (sort v)));只有当所有这些试验都通过时,它才会报告成功。

如果任何试验失败,则 test.check 会尝试将输入“缩减”到最小的失败示例,然后报告原始失败示例和缩减后的示例。例如,此错误属性声称在对整数向量进行排序后,第一个元素应该小于最后一个元素

(def prop-sorted-first-less-than-last
  (prop/for-all [v (gen/not-empty (gen/vector gen/int))]
    (let [s (sort v)]
      (< (first s) (last s)))))

如果我们使用 tc/quick-check 运行此属性,它将返回类似以下内容

{:num-tests 5,
 :seed 1528580863556,
 :fail [[-3]],
 :failed-after-ms 1,
 :result false,
 :result-data nil,
 :failing-size 4,
 :pass? false,
 :shrunk
 {:total-nodes-visited 5,
  :depth 2,
  :pass? false,
  :result false,
  :result-data nil,
  :time-shrinking-ms 1,
  :smallest [[0]]}}

原始失败示例 [-3](在 :fail 键下给出)已缩减为 [0](在 [:shrunk :smallest] 下),并且还提供了各种其他数据。

生成器

test.check 的不同部分通过命名空间清晰地分离。我们将自下而上进行,从生成器开始,然后是属性,然后是两种运行测试的方法。

生成器由 clojure.test.check.generators 命名空间支持。

内置生成器分为三类:标量(基本数据类型)、集合和组合器。

  • 标量(基本数据类型:数字、字符串等)

  • 集合(列表、映射、集等)

  • 组合器

组合器足够通用,可以支持为任意自定义类型创建生成器。

此外,还有几个用于试验生成器的开发函数。我们将首先介绍这些函数,以便我们可以使用它们来演示生成器的其余功能。

开发工具

gen/sample 函数接受一个生成器并返回该生成器的一组小型示例元素

user=> (gen/sample gen/boolean)
(true false true true true false true true false false)

gen/generate 函数接受一个生成器并返回一个生成的元素,此外还可以指定元素的 sizesize 是一个抽象参数,通常是一个介于 0 到 200 之间的整数。

user=> (gen/generate gen/large-integer 50)
-165175

标量生成器

test.check 带有布尔值、数字、字符、字符串、关键字、符号和 UUID 的生成器。例如

user=> (gen/sample gen/double)
(-0.5 ##Inf -2.0 -2.0 0.5 -3.875 -0.5625 -1.75 5.0 -2.0)

user=> (gen/sample gen/char-alphanumeric)
(\G \w \i \1 \V \U \8 \U \t \M)

user=> (gen/sample gen/string-alphanumeric)
("" "" "e" "Fh" "w46H" "z" "Y" "7" "NF4e" "b0")

user=> (gen/sample gen/keyword)
(:. :Lx :x :W :DR :*- :j :g :G :_)

user=> (gen/sample gen/symbol)
(+ kI G uw jw M9E ?23 T3 * .q)

user=> (gen/sample gen/uuid)
(#uuid "c4342745-9f71-42cb-b89e-e99651b9dd5f"
 #uuid "819c3d12-b45a-4373-a307-5943cf17d90b"
 #uuid "c72b5d34-255f-408f-8d16-4828ed740904"
 #uuid "d342d515-b297-4ed4-91cc-8cd55007e2c2"
 #uuid "6d09c6f3-12d4-4e5e-9de5-0ed32c9fef20"
 #uuid "a572178c-5460-44ee-b992-9d3d26daf8c0"
 #uuid "572cc48e-b3a8-40ca-9449-48af08c617d3"
 #uuid "5f6ed50b-adef-4e7f-90d0-44511900491e"
 #uuid "ddbbfd07-d580-4638-9858-57a469d91727"
 #uuid "c32b7788-70de-4bf5-b24f-1e7cb564a37d")

集合生成器

集合生成器通常是具有其元素生成器参数的函数。

例如

user=> (gen/generate (gen/vector gen/boolean) 5)
[false false false false]

(请注意,此处 gen/generate 的第二个参数不是指定集合的大小,而是前面提到的抽象 size 参数;gen/generate 的默认值为 30)

还有异构集合的生成器,其中最重要的是 gen/tuple

user=> (gen/generate (gen/tuple gen/boolean gen/keyword gen/large-integer))
[true :r -85718]

一些集合生成器还可以进一步定制

user=> (gen/generate (gen/vector-distinct (gen/vector gen/boolean 3)
                                          {:min-elements 3 :max-elements 5}))
[[true  false false]
 [true  true  false]
 [false false true]
 [false true  true]]

生成器组合器

标量和集合生成器可以生成各种结构,但创建重要的自定义生成器需要使用组合器。

gen/one-of

gen/one-of 接受一个生成器集合并返回一个可以从任何一个生成器中生成值的生成器

user=> (gen/sample (gen/one-of [gen/boolean gen/double gen/large-integer]))
(-1.0 -1 true false 3 true true -24 -0.4296875 3)

还有 gen/frequency,它类似但允许为每个生成器指定权重。

gen/such-that

gen/such-that 使用谓词将现有生成器限制为其值的子集

user=> (gen/sample (gen/such-that odd? gen/large-integer))
(3 -1 -1 -1 -3 5 -11 1 -1 -5)

但是,这里没有魔法:生成匹配谓词值的唯一方法是反复生成值,直到碰巧有一个匹配为止。这意味着如果谓词没有太多次连续匹配,gen/such-that 会随机失败

user=> (count (gen/sample (gen/such-that odd? gen/large-integer) 10000))
ExceptionInfo Couldn't satisfy such-that predicate after 10 tries.  clojure.core/ex-info (core.clj:4754)

gen/sample 调用(请求 10000 个奇数)失败,因为 gen/large-integer 大约有一半的时间返回偶数,因此连续看到十个偶数并不算特别不可能。

除非谓词很可能成功,否则应避免使用 gen/such-that。在其他情况下,通常有其他方法来构建生成器,正如我们将在 gen/fmap 中看到的那样。

gen/fmap

gen/fmap 允许您通过提供一个函数来修改它生成的的值来修改任何生成器。您可以使用它通过生成它们所需的片段,然后在 gen/fmap 函数中组合它们来构建任意结构或自定义对象

user=> (gen/generate (gen/fmap (fn [[name age]]
                                 {:type :humanoid
                                  :name name
                                  :age  age})
                               (gen/tuple gen/string-ascii
                                          (gen/large-integer* {:min 0}))))
{:type :humanoid, :name ".o]=w2hZ", :age 14}

gen/fmap 的另一个用途是使用目标转换来限制或倾斜另一个生成器的分布。例如,要将通用整数生成器转换为奇数生成器,您可以使用 gen/fmap 函数 #(+ 1 (* 2 %))(这也具有使分布范围加倍的效果)或 #(cond-> % (even? %) (+ 1))(它没有)。

这是一个仅生成大写字符串的生成器

user=> (gen/sample (gen/fmap #(.toUpperCase %) gen/string-ascii))
("" "" "JT" "" ">Y1@" "" "]-" "XCJ@C" "<ANF.\"|" "I@O\"M")

gen/bind

最先进的组合器允许分多个阶段生成内容,其中后期阶段的生成器使用早期阶段生成的值构建。

虽然这听起来可能很复杂,但签名与 gen/fmap 几乎没有区别:参数顺序相反,并且期望函数返回生成器而不是值。

例如,假设您想以两种不同的顺序生成一个随机数字列表(例如,测试一个应该与集合排序无关的函数)。使用 gen/fmap 或任何其他组合器很难做到这一点,因为直接生成两个集合通常会得到具有不同元素的集合,如果您只生成一个,则您没有机会使用另一个生成器(例如 gen/shuffle)来重新排序它,该生成器可能能够重新排序它。

gen/bind 为我们提供了我们需要的两阶段结构

user=> (gen/generate (gen/bind (gen/vector gen/large-integer)
                               (fn [xs]
                                 (gen/fmap (fn [ys] [xs ys])
                                           (gen/shuffle xs)))))
[[-5967 -9114 -2 -4 68583042 223266 540 3 -100]
 [223266 -9114 -2 -100 3 540 -5967 -4 68583042]]

这里的结构有点晦涩,因为我们传递给 gen/bind 的函数不能简单地调用 (gen/shuffle xs)——如果它调用了,整个生成器只会返回 (gen/shuffle xs) 生成的那个集合;为了使用 gen/shuffle 生成第二个集合并返回原始集合,我们使用 gen/fmap 将两者组合成一个向量。

这是一个稍微简单一点的结构,但代价是进行额外的洗牌

user=> (gen/generate (gen/bind (gen/vector gen/large-integer)
                               (fn [xs] (gen/vector (gen/shuffle xs) 2))))
[[-4 254202577 -27512 1596863 0 6] [-4 6 254202577 1596863 -27512 0]]

但是,一个可能具有更好可读性的选项是使用 gen/let 宏,该宏使用类似 let 的语法来描述 gen/fmapgen/bind 的用法

user=> (gen/generate
        (gen/let [xs (gen/vector gen/large-integer)
                  ys (gen/shuffle xs)]
          [xs ys]))
[[0 47] [0 47]]

属性

属性是一个实际的测试——它将生成器与您要测试的函数结合起来,并检查该函数在给定生成的值时是否按预期工作。

属性是使用 clojure.test.check.properties/for-all 宏创建的。

第一个示例 中的属性生成一个向量,然后三次调用被测函数(sort)。

属性还可以组合多个生成器,例如

(def +-is-commutative
  (prop/for-all [a gen/large-integer
                 b gen/large-integer]
    (= (+ a b) (+ b a))))

实际上运行属性有两种方法,这就是接下来的两部分要介绍的内容。

quick-check

运行测试的独立且功能性的方法是通过 clojure.test.check 命名空间中的 quick-check 函数。

它接受一个属性和一个试验次数,并最多运行该属性那么多次数,返回一个描述成功或失败的映射。

请参阅 上面的示例

defspec

defspec 是一个用于编写基于属性的测试的宏,这些测试会被 clojure.test 识别和运行。

quick-check 的区别部分只是语法上的,部分在于它定义了一个测试而不是运行它。

例如,本指南中的第一个 quick-check 示例 也可以这样编写

(require '[clojure.test.check.clojure-test :refer [defspec]])

(defspec sort-is-idempotent 100
  (prop/for-all [v (gen/vector gen/int)]
    (= (sort v) (sort (sort v)))))

鉴于此,在同一命名空间中调用 (clojure.test/run-tests) 会产生以下输出

Testing my.test.ns
{:result true, :num-tests 100, :seed 1536503193939, :test-var "sort-is-idempotent"}

Ran 1 tests containing 1 assertions.
0 failures, 0 errors.

其他文档

有关其他文档,请参阅 test.check 自述文件

原作者:Gary Fredericks