Clojure

test.check

はじめに

test.checkは、QuickCheckにインスパイアされた、Clojure用のプロパティベースのテストライブラリです。

このガイドはバージョン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回「実行」します。つまり、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のさまざまな部分は、名前空間によって明確に区別されています。ジェネレータから始め、次にプロパティ、そしてテストを実行する2つの方法に進みます。

ジェネレータは、`clojure.test.check.generators`名前空間によってサポートされています。

組み込みジェネレータは、スカラー(基本データ型)、コレクション、コンビネータの3つのカテゴリに分類されます。

  • スカラー(基本データ型:数値、文字列など)

  • コレクション(リスト、マップ、セットなど)

  • コンビネータ

コンビネータは、任意のカスタム型のジェネレータを作成するために十分な汎用性があります。

さらに、ジェネレータを試行するためのいくつかの開発関数があります。ジェネレータの他の機能を示すために、最初にそれらを紹介しましょう。

開発ツール

`gen/sample`関数はジェネレータを受け取り、そのジェネレータから小さなサンプル要素のコレクションを返します。

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

`gen/generate`関数はジェネレータを受け取り、単一の生成された要素を返し、さらに要素の`size`を指定することもできます。`size`は抽象的なパラメータであり、一般的には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`の2番目の引数は、コレクションのサイズを指定するのではなく、前に述べた抽象的な`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`は約半分の場合に偶数を返すため、10個の偶数が連続して表示されることは非常にまれではないからです。

述語が成功する可能性が高い場合を除き、`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のもう1つの用途は、ターゲット変換を使用して別のジェネレータの分布を制限または歪ませることです。たとえば、一般的な整数ジェネレータを奇数のジェネレータに変換するには、関数`#(+ 1 (* 2 %))`(分布の範囲を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`とほとんど変わりません。引数の順序が逆になり、関数は値ではなくジェネレータを返すことが期待されます。

例として、2つの異なる順序でランダムな数値のリストを生成したいとします(たとえば、コレクションの順序に依存しないはずの関数をテストするため)。`gen/fmap`や他のコンビネータを使用してもこれは難しいでしょう。なぜなら、直接2つのコレクションを生成すると、一般的に異なる要素を持つコレクションが生成されるため、1つだけ生成すると、別のジェネレータ(例:`gen/shuffle`)を使用してそれを再順序付ける機会がありません。

`gen/bind`は、まさに必要な2段階の構造を提供します。

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)`によって生成された1つのコレクションを返すだけです。`gen/shuffle`を使用して2番目のコレクションを生成し、元の集合も返すために、`gen/fmap`を使用して2つをベクトルに結合します。

もう1つの構造は、余分なシャッフルを行うことで少し簡単になります。

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/fmap`と`gen/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`)を3回呼び出します。

プロパティは複数のジェネレータを組み合わせることもできます。例えば

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

プロパティを実際に実行するには2つの方法があり、それが次の2つのセクションで説明する内容です。

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