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 README

原始作者:Gary Fredericks