clojure.specを利用した場合のテストの書き方について
あるいは、clojure.specのジェネレータを活かして、プロパティベーステストをする方法を考えてみた話。
結論
まだ完全に理解できていないところが多いから間違ってることを書いているかも
stest/instrumentはテスト時にも有効にしておこう
stest/checkはそんなに便利じゃない(シーンがある)
少なくともデータベースを利用する関数をclojure.spec(というよりclojure.test.check?)でテストするのはしんどい気がした
前提
一般的なWebアプリケーションを作っている
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 (long (rand (- end start)))))
code:example/spec.clj
(ns example.spec
(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の結果がうるさい
stest/checkの結果はstest/summarize-resultsという関数を利用することによって成功/失敗した数だけを分かるように簡略化することができる
{:total 1, :check-passed 1}みたいな
stest/summarize-resultsを利用して、テストの件数と成功した件数を比較して一致すればok?
ただ、こけると原因不明になってしまいつらい
こけたときだけサマライズする前の結果を出力する?
DBに接続するような関数に対して使うのはかなりつらい
あるいは副作用を起こすような関数?
そもそもclojure.test.checkが副作用を起こすようなものを対象にするのがつらい?
with-test-dbみたいなものを併用しようとすると困ることがある
これに関してはもはやテストで利用しようとする方がつらいのではないか、という気がしてきた。
どのようにテストを書くと良いか考えてみた(みている)
上述のことを踏まえてどのようにコードを書くと良いか考えていた。例えば以下のようなコードを想像する。
code:example.clj
(ns example
(-> (sql/select :*)
(sql/from :users)
sql-format/format
(->> (jdbc/query db))
first))
code:example/spec.clj
(ns example.spec
(s/def ::uuid uuid?)
(s/def ::fullname (s/and string? (complement str/blank?)))
(s/def ::user
(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
(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}
(t/is (= (:total res)
(:check-passed res))))))
(tc/defspec prop-test2 10
(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
(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
(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の設定が面倒だったので… 他のテストと比較すると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はそこまでして、書く必要があるのか?という気がする結果になった。
まとめ
DBに接続するようなテストを書く場合は、ジェネレータを利用する程度に留めておくと良いかもしれない