clojure.specを利用した場合のテストの書き方について
あるいは、clojure.specのジェネレータを活かして、プロパティベーステストをする方法を考えてみた話。
最近clojure.specを使い始めた。clojure.specはとても便利なんだけど、これを利用してテストを書くのに四苦八苦している。とりあえず、色々と試行錯誤したところでメモを残しておくことにする。
結論
まだ完全に理解できていないところが多いから間違ってることを書いているかも
stest/instrumentはテスト時にも有効にしておこう
stest/checkはそんなに便利じゃない(シーンがある)
少なくともデータベースを利用する関数をclojure.spec(というよりclojure.test.check?)でテストするのはしんどい気がした
前提
一般的なWebアプリケーションを作っている
DB(PostgreSQL)を利用している
specはspecディレクトリを作っている
devプロファイルを利用しているので読み込まれるのは開発/テスト時のみ
s/conformを利用したい場合のみ、srcディレクトリなどに置くようにしている
specを定義しているネームスペースは.specというsuffixを付けている
例えば example.coreというネームスペースに対するspecはexample.core.specというふうになる
code:example.clj
(ns example)
(defn ranged-rand
"Returns random int in range start <= rand < end"
start end
(+ start (long (rand (- end start)))))
code:example/spec.clj
(ns example.spec
(:require example :as e
clojure.spec.alpha :as s)
(s/fdef e/ranged-rand
:args (s/and (s/cat :start int? :end int?)
#(< (:start %) (:end %)))
:ret int?
:fn (s/and #(>= (:ret %) (-> % :args :start))
#(< (:ret %) (-> % :args :end))))
stest/instrumentについて
clojure.spec.test.alpha/instrumentのこと(以下、stest/instrument)
関数に対してのspecがあれば、引数のチェックを行なうようになる
stest/instrumentに引数としてシンボルなどが渡されなければ、現在既に読み込まれている関数全てに対して引数のチェックをspecで行なう
当然だけど、specのない関数は無視
stest/instrumentに引数としてシンボルを渡すと、その関数の引数に対するチェックを有効にする
テストの実行時にも忘れずに有効にしておくと嬉しい
前提のところに書いた通り、specのネームスペースを分けているので、何もしなければテスト時にstest/instrumentを利用することができない。なので、テストを実行する前に必ずspecディレクトリ以下のネームスペースを全て読み込んでから、stest/instrumentを実行している。これにはbultitudeを利用している。
stest/checkについて
clojure.spec.test.alpha/checkのこと(以下、stest/check)
関数に対してspecがあれば、clojure.test.check/quick-checkを利用する感じでプロパティベーステストを実行してくれる
引数を渡せば、その関数に対してテストを実行する
引数がなければ全てのspecがある関数に対してテストを実行する
不便な点が結構あって、テストコードに利用するのはちょっと不向きかも?
stest/checkの結果がうるさい
そもそもclojure.test.checkはそんなもんといえばそんなもん
stest/checkの結果はstest/summarize-resultsという関数を利用することによって成功/失敗した数だけを分かるように簡略化することができる
{:total 1, :check-passed 1}みたいな
clojure.testと組み合わせるのが難しい、というか無理?
stest/summarize-resultsを利用して、テストの件数と成功した件数を比較して一致すればok?
ただ、こけると原因不明になってしまいつらい
こけたときだけサマライズする前の結果を出力する?
DBに接続するような関数に対して使うのはかなりつらい
あるいは副作用を起こすような関数?
そもそもclojure.test.checkが副作用を起こすようなものを対象にするのがつらい?
with-test-dbみたいなものを併用しようとすると困ることがある
これに関してはもはやテストで利用しようとする方がつらいのではないか、という気がしてきた。
どのようにテストを書くと良いか考えてみた(みている)
上述のことを踏まえてどのようにコードを書くと良いか考えていた。例えば以下のようなコードを想像する。
code:example.clj
(ns example
(:require clojure.java.jdbc :as jdbc
honeysql.format :as sql-format
honeysql.helpers :as sql))
(defn find-by-uuid db uuid
(-> (sql/select :*)
(sql/from :users)
(sql/where := :uuid uuid)
sql-format/format
(->> (jdbc/query db))
first))
code:example/spec.clj
(ns example.spec
(:require clojure.java.jdbc.spec :as jdbc.spec
clojure.spec.alpha :as s
example :as e
clojure.string :as str))
(s/def ::uuid uuid?)
(s/def ::fullname (s/and string? (complement str/blank?)))
(s/def ::user
(s/keys :req-un ::uuid ::fullname))
(s/fdef e/find-by-uuid
:args (s/cat :db ::jdbc.spec/db-spec
:uuid ::uuid)
:ret (s/or :user ::user
:none nil?))
こんな感じで任意のデータベースからデータを取得する関数があるとする。これに対して、clojure.specを活用してテストを書こうとすると以下のようになると思う。
code:example-test.clj
(ns example-test
(:require clojure.java.jdbc.spec :as jdbc.spec
clojure.spec.alpha :as s
clojure.spec.gen.alpha :as sgen
clojure.spec.test.alpha :as stest
clojure.test :as t
clojure.test.check.clojure-test :as tc
clojure.test.check.properties :as prop
example :as sut
example.spec :as sut.spec
example.test-helper :as h))
(t/deftest prop-test1
(h/with-test-db db
(let [res (stest/summarize-results
(stest/check `sut/find-by-uuid
{::tc/opts {:num-tests 10}
:gen {::jdbc.spec/db-spec #(sgen/return db)}}))]
(t/is (= (:total res)
(:check-passed res))))))
(tc/defspec prop-test2 10
(let [fspec (-> #'sut/find-by-uuid s/get-spec s/spec)
overrides {::jdbc.spec/db-spec #(sgen/return "foobar")}]
(prop/for-all [uuid (-> fspec :args (s/gen overrides))]
(h/with-test-db db
(s/valid? (-> fspec :ret)
(sut/find-by-uuid db uuid))))))
(t/deftest prop-test3
(h/with-test-db db
(let [fspec (-> #'sut/find-by-uuid s/get-spec s/spec)
overrides {::jdbc.spec/db-spec #(sgen/return db)}]
(doseq [db-spec uuid (-> fspec :args (s/gen overrides) sgen/sample)]
(t/is (s/valid? (-> fspec :ret)
(sut/find-by-uuid db-spec uuid)))))))
(t/deftest prop-test4
(h/with-test-db db
(let fspec (-> #'sut/find-by-uuid s/get-spec s/spec)
(doseq uuid (sgen/sample (s/gen ::sut.spec/uuid))
(t/is (s/valid? (-> fspec :ret)
(sut/find-by-uuid db uuid)))))))
補足: h/with-test-dbはComponentやIntegrantで作ったシステムを起動して、DBのコンポーネントだけを利用するためのマクロです。前処理でマイグレーションを行ない、後処理でDBを綺麗にしています。
とりあえず、思いつく実装を4種類並べてみました。ちなみにどのテストもパスしますが、DBにデータを登録する処理を端折ってるのでsut/find-by-uuidの結果は常にnilです。それぞれ、簡単に説明していきます。
prop-test1
stest/checkを利用した例
このように書くとサマライズされた結果を見なければテストが成功したかどうかを知ることはできません
サマライズ前のデータをひとつひとつチェックしていってもいいですが、結局は同じことです
既に述べた通り、この状態でテストがこけるとテストがこけた理由が分からなくなるので、適当に失敗したときにログを吐かせる必要があります
prop-test2
clojure.test.check.clojure-test/defspecを利用してみた例
prop/for-allのインデントが狂ってるのはEmacsの設定が面倒だったので…
とりあえず、clojure.test.checkと同じように書けるという点は良さそう
他のテストと比較するとh/with-test-dbが内側にあることに注目
prop/for-allがジェネレータを返却するので、外側に書くとh/with-test-dbを抜けた後にDBアクセスしようとしてこける
内側に書いてしまうとDBとのコネクションを何度も確立することになるので、若干テスト実行の負荷が高い
prop-test3とprop-test4
この辺はあまり違いがない
強いて言うなら
3はわざわざ関数からスペックを取り出してジェネレータを作っている
4は引数のスペックを持ってきてジェネレータを作っている
比較的この方法は低コスト(通常のclojure.testを書く要領)で書けるので便利ではある
ただ、4の場合はs/fdefでスペックを関数に紐付ける意味が薄くなってしまうので、なんだかなー?とは思う
という感じでprop-test1とprop-test2はそこまでして、書く必要があるのか?という気がする結果になった。
まとめ
副作用や外部APIから独立した純粋な関数であれば、プロパティベーステストも捗るかもしれない
DBに接続するようなテストを書く場合は、ジェネレータを利用する程度に留めておくと良いかもしれない
#clojure #clojure.spec #テスト