TL;DR
- 関数が spec を持っていれば、
stest/checkで:argsspec に従う 1000 個の引数を生成し、戻り値と引数との関係を検証する生成テストを自動で実行できる。 s/exerciseで、spec から生成される入力値のサンプルを確かめられる。s/andは最初の spec のジェネレータで値を生成し、後の spec でフィルタする。- 自動生成されるジェネレータが効率的でない、または有効な値を生成できないときは、
s/with-genやgen/fmapでカスタムジェネレータを作る。
内容
spec による生成機能はすべて、本番実行時に含まれないように、テストまたは開発環境の依存関係として test.check ライブラリを含める必要がある。
自動生成テストとは
従来の単体テストでは、プログラマが関数に複数の具体的な入力値を書き、各入力値で関数を呼び出し、その戻り値をアサーションする。
一方、生成テスト(generative testing)は、数千のランダムな入力値を生成して関数を呼び出し、各出力に対して一連のプロパティを検証する。プロパティベーステストとも呼ばれる。
関数が spec を持っている場合、関数の生成テストを自動的に実行できる。
関数の検証(stest/check)
spec はclojure.spec.test.alpha にある check という関数を使って、自動生成テストを実行する。check には、s/fdef で定義した spec を渡す。
check を呼び出すと、関数の :args spec に従って、有効な引数を 1000 個生成する。check は各引数に対して関数を呼び出し、戻り値が :ret spec に従って有効であること、:fn spec に従って引数と戻り値の関係が有効であることを確認する。
clojure.core/symbol のサンプルテスト
例として、Clojure のコア関数である symbol に対してテストを実行する。
(doc symbol)
;; -------------------------
;; clojure.core/symbol
;; ([name] [ns name])
;; Returns a Symbol with the given namespace and name. Arity-1 works
;; on strings, keywords, and vars.この関数の spec は、次のように定義できる。
(s/fdef clojure.core/symbol
:args (s/cat :ns (s/? string?) :name string?)
:ret symbol?
:fn (fn [{:keys [args ret]}]
(and (= (name ret) (:name args))
(= (namespace ret) (:ns args)))))これを使って、自動生成テストを実行する。
(stest/check 'clojure.core/symbol)
;; ({:spec #object[clojure.spec.alpha$fspec_impl$reify__2518 ...],
;; :clojure.spec.test.check/ret {
;; :result true,
;; :pass? true,
;; :num-tests 1000,
;; :time-elapsed-ms 103,
;; :seed 1693915255927},
;; :sym clojure.core/symbol})出力結果から分かるように、stest/check は clojure.core/symbol に対して 1000 回のテストを実行し、問題がなかったことを示している。
エラーが発生すると、test.check ライブラリはエラーが発生する最も単純な入力値を生成する。これにより、エラーの再現や修正を行いやすくなる。
入力値のサンプルの生成(s/exercise)
symbol の引数の spec は、(s/cat :ns (s/? string?) :name string?) だった。この spec から check がどのようにランダムな引数を生成するかをシミュレートするには、s/exercise を使う。この関数は入力値のサンプルと、それらの適合値のペアを返す。
(s/exercise (s/cat :ns (s/? string?) :name string?))
; ([("") {:name ""}]
; [("0") {:name "0"}]
; [("" "2C") {:ns "", :name "2C"}]
; [("xr") {:name "xr"}] [("") {:name ""}]
; [("p5HK" "v5lD") {:ns "p5HK", :name "v5lD"}]
; [("1h03rw" "5d79E") {:ns "1h03rw", :name "5d79E"}]
; [("w2a3a00" "") {:ns "w2a3a00", :name ""}]
; [("Q6UdL7" "yjh5") {:ns "Q6UdL7", :name "yjh5"}]
; [("" "Hh8l5Ka") {:ns "", :name "Hh8l5Ka"}])この例ではうまく機能したが、spec が自動的に有効なジェネレータを生成できないケースもある。この場合はカスタムジェネレータを別に作る必要がある。
s/and を使ってジェネレータを組み合わせる
s/and は複数の spec を組み合わせる一般的な方法の 1 つである。このマクロは最初のコンポーネントの spec のジェネレータを使って値を生成し、その後のコンポーネントの spec で値をフィルタリングする。
ジェネレータがうまく動作しないサンプル
例として、100 より大きな奇数の spec を考える。
(defn big? [x] (> x 100))
(s/def ::big-odd (s/and odd? big?))これは spec としては機能するが、ジェネレータはうまく動作しない。
(s/exercise ::big-odd)
;; Unable to construct gen at: [] for: odd?原因は次の 2 つである。
- 述語関数の多くは自動的にマッピングされたジェネレータを持っているが、
odd?は複数の数値型で動作するので、ジェネレータがマッピングされていない - 自作した述語(
big?)には、ジェネレータがマッピングされていない
s/and を使ってジェネレータが動作するように修正
これを修正するには、マッピングされたジェネレータを持つ述語関数と s/and で組み合わせればよい。
(s/def ::big-odd (s/and int? odd? big?))これでジェネレータが動作する。
(s/exercise ::big-odd)
;; ([3619 3619]
;; [1023 1023]
;; [117 117]
;; ... )s/and で作った spec のジェネレータをデバッグするときに覚えておくべきことは、最初のコンポーネントの spec のジェネレータだけが使われる点である。また、2 つ目以降のコンポーネントの spec で値がフィルタリングされるので、有効な値を生成するのに時間がかかる場合がある。これを解決するには、カスタムジェネレータを使うのがよい。
カスタムジェネレータを生成する
なぜカスタムジェネレータを使う必要があるのか
自動生成されるジェネレータが効率的でない場合や、有効な値が生成されない場合がある。
有効な値が生成されないケースを見ていく。たとえば、引数の 1 つがコレクションで、もう 1 つがそのコレクションの要素であるような関数が挙げられる。自動生成されるジェネレータは、それぞれを独立に生成するので、有効な値の組み合わせを生成するのは難しい。この問題はカスタムジェネレータを使うと回避できる。
カスタムジェネレータを指定する
s/coll-of や s/map-of、s/every、s/every-kv、s/keys など、一部の spec には、カスタムジェネレータを指定できるオプションが用意されている。
また、s/with-gen を使って、既存の spec にカスタムジェネレータを追加することもできる。
加えて、stest/instrument や stest/check など一部の関数では、名前やパスによるジェネレータの一時的なオーバーライドもできる。このオーバーライドは、呼び出しの実行中だけ有効である。
カスタムジェネレータを作る
ジェネレータはいくつかの方法で作れるが、最も簡単な方法は、spec を作り、s/gen を使ってそのジェネレータを取得する方法である。
慣習として gen とエイリアスされる clojure.spec.gen.alpha 名前空間には、他のジェネレータや、ジェネレータを組み合わせるための関数が含まれている。
たとえば、列挙値で実装した :marble/color の代わりに、固定値だけを生成するカスタムジェネレータを作る方法を見る。
(s/def :marble/color #{:red :blue :green})
(s/def :marble/color-red
(s/with-gen :marble/color #(s/gen #{:red})))
(s/exercise :marble/color-red)
; ([:red :red] [:red :red] ... )この手法は入力値のランダム性を減らしたり、生成が難しい複雑な入力値を直接提供したりするために使う。
gen/fmap 関数を使って生成された各値に関数を適用する
clojure.spec.gen.alpha 名前空間には、標準の Clojure の型やコレクション、その他の要素を生成するための多くの関数が含まれている。
その中に、gen/fmap という関数がある。この関数を使うと、特定のジェネレータを起点とし、そのジェネレータから生成される各値を変更するために、別の関数を利用できる。
たとえば、特定の接頭辞で始まる文字列を生成するために、ランダムな接尾辞を生成し、それに接頭辞を結合できる。
(s/def ::sku
(s/with-gen (s/and string? #(str/starts-with? % "SKU-"))
(fn [] (gen/fmap #(str "SKU-" %) (s/gen string?)))))ここでは、gen/fmap は有効な文字列のジェネレータから始め、生成された値に関数を適用して、文字列の接頭辞として SKU- という文字列を付け加えている。そしてその後、spec に関連付けられる。
試しに実行してみる。
(s/exercise ::sku)
; (["SKU-" "SKU-"]
; ["SKU-" "SKU-"]
; ["SKU-q" "SKU-q"] ... )キーワード
自分の理解
lein new で生成したプロジェクトに test.check を追加するには、次を project.clj の依存関係に追加してから lein deps を実行する。
:dependencies [[org.clojure/clojure "1.11.1"]
[org.clojure/test.check "1.1.1"]]プロパティベーステストについては、『実践プロパティベーステスト ― PropErとErlang/Elixirではじめよう』がある。