前書き
以前に書いた記事の中でforとdoseqがまとめてテストされているということに触れました。
個人的にはそのテストの方法が驚きで、Clojureのテストについての考えは自分になじみのないものでした。
もう少し具体的に表現するのなら、「テストが柔らかい」と感じたのです。「テストライブラリは素材で自由にいじってもいい」といういい方もできるのでしょうか。
その命題が真であるならば、Clojureのテストの方法はきっと驚きに満ちているだろう、というのが今回の記事のモチベーションです。
具体的にはClojureのリポジトリの中から、テストに関するヘルパー関数、マクロを適当に拾い上げて、分類・解説を加えていきます。
clojure.testの主となる関数やマクロについては特に触れません。今回の興味は材料をどう調理するかであって、材料そのものではないからです。
といいながら、材料それ自体と関係ないものも面白いと思えばとりあげるので、了承ください。
ヘルパーを眺める
名前空間系
かぶらない名前空間を作る
(defmacro call-ns
"Call ns with a unique namespace name. Return the result of calling ns"
[] `(ns a#))
(defmacro call-ns-sym
"Call ns wih a unique namespace name. Return the namespace symbol."
[] `(do (ns a#) 'a#))
(deftest test-dynamic-ns
(testing "a call to ns returns nil"
(is (= nil (call-ns))))
(testing "requiring a dynamically created ns should not throw an exception"
(is (= nil (let [a (call-ns-sym)] (require a))))))
そのまま読むと、gensymで重複しない新しい名前空間を作り、それをrequireできないかどうか確認しているようです。
これ自体はシンプルなのですが、Clojureのテストではかぶらない名前空間を作ってその中で色々いじくるということがあるので、その基本形という感じですね。
(defn temp-ns
"Create and return a temporary ns, using clojure.core + uses"
[& uses]
(binding [*ns* *ns*]
(in-ns (gensym))
(apply clojure.core/use 'clojure.core uses)
*ns*))
(defmacro eval-in-temp-ns [& forms]
`(binding [*ns* *ns*]
(in-ns (gensym))
(clojure.core/use 'clojure.core)
(eval
'(do ~@forms))))
上記で言った、かぶらない名前空間をつくって処理する系のヘルパーです。
かぶらない名前空間を作るメリットとしては、
- 適当な名前でdefしても問題なくなる
- テスト用の変数名とかを動的に作る際に命名にこだわる必要がなくなる
- テストしている名前空間と重複するのを避ける
下はfooという変数名でテストをしたかったのかなぁという見方をしています。
(deftest defn-error-messages
(testing "multiarity syntax invalid parameter declaration"
(is (fails-with-cause?
clojure.lang.ExceptionInfo
#"Call to clojure.core/defn did not conform to spec"
(eval-in-temp-ns (defn foo (arg1 arg2))))))
(testing "multiarity syntax invalid signature"
(is (fails-with-cause?
clojure.lang.ExceptionInfo
#"Call to clojure.core/defn did not conform to spec"
(eval-in-temp-ns (defn foo
([a] 1)
[a b])))))
(testing "assume single arity syntax"
(is (fails-with-cause?
clojure.lang.ExceptionInfo
#"Call to clojure.core/defn did not conform to spec"
(eval-in-temp-ns (defn foo a)))))
変数の再定義
(defn set-var-roots
[maplike]
(doseq [[var val] maplike]
(alter-var-root var (fn [_] val))))
(defn with-var-roots*
"Temporarily set var roots, run block, then put original roots back."
[root-map f & args]
(let [originals (doall (map (fn [[var _]] [var @var]) root-map))]
(set-var-roots root-map)
(try
(apply f args)
(finally
(set-var-roots originals)))))
(defmacro with-var-roots
[root-map & body]
`(with-var-roots* ~root-map (fn [] ~@body)))
alter-var-rootで変数を上書きして、あとからもとに戻しています。一瞬戸惑いますが、やっていることは比較的シンプルです。
上書きできるとやりやすいテストもあるのかなと思いましたが、呼ばれているのは一か所だけでした。
ヘルパーの中にある関数は意外にどこからも呼ばれていなかったりします。
bindingの上書き
標準出力
(defmacro with-err-print-writer
"Evaluate with err pointing to a temporary PrintWriter, and
return err contents as a string."
[& body]
`(let [s# (java.io.StringWriter.)
p# (java.io.PrintWriter. s#)]
(binding [*err* p#]
~@body
(str s#))))
(defmacro with-err-string-writer
"Evaluate with err pointing to a temporary StringWriter, and
return err contents as a string."
[& body]
`(let [s# (java.io.StringWriter.)]
(binding [*err* s#]
~@body
(str s#))))
(defmacro should-print-err-message
"Turn on all warning flags, and test that error message prints
correctly for all semi-reasonable bindings of *err*."
[msg-re form]
`(binding [*warn-on-reflection* true]
(is (re-matches ~msg-re (with-err-string-writer (eval-in-temp-ns ~form))))
(is (re-matches ~msg-re (with-err-print-writer (eval-in-temp-ns ~form))))))
(defmacro should-not-reflect
"Turn on all warning flags, and test that reflection does not occur
(as identified by messages to *err*)."
[form]
`(binding [*warn-on-reflection* true]
(is (nil? (re-find #"^Reflection warning" (with-err-string-writer (eval-in-temp-ns ~form)))))
(is (nil? (re-find #"^Reflection warning" (with-err-print-writer (eval-in-temp-ns ~form)))))))
標準エラー出力の切り替えかなと思います。
bindingをmacroでラップしてというパターンはかなり多いです。マクロを作る理由の中でもトップクラスだったように記憶しています。
あとはこちらは返り値として、出力されたメッセージを返すという仕様にしてテストしているみたいです。
出力をすげかえてテストというのはJavaの時によく見た記憶が少しあります。
動的なテストの定義
テストをまとめる
;; This is just a macro to make my tests a little cleaner
(defn- back-match [x y] (re-matches y x))
(defmacro simple-tests [name & test-pairs]
`(deftest ~name
~@(for [[x y] (partition 2 test-pairs)]
(cond
(instance? java.util.regex.Pattern y)
`(is (#'clojure.test-clojure.pprint.test-helper/back-match ~x ~y))
(instance? java.lang.String y) `(is (= ~x (platform-newlines ~y)))
:else `(is (= ~x ~y))))))
使用例は次のような感じになります。
(simple-tests d-tests
(cl-format nil "~D" 0) "0"
(cl-format nil "~D" 2e6) "2000000"
(cl-format nil "~D" 2000000) "2000000"
(cl-format nil "~:D" 2000000) "2,000,000"
(cl-format nil "~D" 1/2) "1/2"
(cl-format nil "~D" 'fred) "fred"
)
formatのテストですね。
テストをまとめていいのかという気持ちはありましたが、実際テスト量をみるとテストの書き方を工夫しないとどうにもならないのは感じました。
こちらの書き方のほうがよりシンプルに可読性の高いテストになっています。
テストを短くわかりやすく書くためにはどうすればいいのかという試行錯誤が見て取れてよいマクロだと思います。
これはdeftest、isの両方が含まれているので基本形という感じはします。
isマクロを見やすくする
(defmacro test-that
"Provides a useful way for specifying the purpose of tests. If the first-level
forms are lists that make a call to a clojure.test function, it supplies the
purpose as the msg argument to those functions. Otherwise, the purpose just
acts like a comment and the forms are run unchanged."
[purpose & test-forms]
(let [tests (map
#(if (= (:ns (meta (resolve (first %))))
(the-ns 'clojure.test))
(concat % (list purpose))
%)
test-forms)]
`(do ~@tests)))
isマクロを大量に定義するときに便利なマクロです。あとは可読性が目的のような感じはします。
(deftest Collections
(in-test-ns
(test-that
"Vectors and Maps yield vectors and (hash) maps whose contents are the
evaluated values of the objects they contain."
(is (= (eval '[x y 3]) [1 2 3]))
(is (= (eval '{:x x :y y :z 3}) {:x 1 :y 2 :z 3}))
(is (instance? clojure.lang.IPersistentMap (eval '{:x x :y y})))))
使用例はこんな感じです。展開時にisの第3引数(メッセージ?)に動的にメッセージを突っ込んでいます。
あとはトップに何をした以下の説明が見えるのでテスト全体の可読性を上げたいのかなというふうにとらえました。
今回の記事の中では1番メタ的に材料を使っている感じはしました。
あと、名前解決が必要な理由はいまいちわかりませんでした。なんででしょう。
同じファイルにある下記のマクロと組み合わせるのが前提になっている気もします。
(defmacro in-test-ns [& body]
`(binding [*ns* *ns*]
(in-ns 'clojure.test-clojure.evaluation)
~@body))
新しいテスト方法を定義する
(defmacro defequivtest
;; f is the core fn, r is the reducers equivalent, rt is the reducible ->
;; coll transformer
[name [f r rt] fns]
`(deftest ~name
(let [c# (range -100 1000)]
(doseq [fn# ~fns]
(is (= (~f fn# c#)
(~rt (~r fn# c#))))))))
(defequivtest test-map
[map r/map #(into [] %)]
[inc dec #(Math/sqrt (Math/abs %))])
(defequivtest test-mapcat
[mapcat r/mapcat #(into [] %)]
[(fn [x] [x])
(fn [x] [x (inc x)])
(fn [x] [x (inc x) x])])
これでテストケース1000個作りました!って言えますね。
新しいテストの構文を作っているような形でClojureのテストの柔軟性を表したいいテストだと思います。
testの意味をはっきりさせつつ、多くのテストケースを端的にカバーしているというところで素敵です。
assertとしてのテスト
(defmacro for-all
[& args]
`(dorun (for ~@args)))
(defn assert-valid-hierarchy
[h]
(let [tags (hierarchy-tags h)]
(testing "ancestors are the transitive closure of parents"
(for-all [tag tags]
(is (= (transitive-closure tag #(parents h %))
(or (ancestors h tag) #{})))))
(testing "ancestors are transitive"
(for-all [tag tags]
(is (= (transitive-closure tag #(ancestors h %))
(or (ancestors h tag) #{})))))
(testing "tag descendants are transitive"
(for-all [tag tags]
(is (= (transitive-closure tag #(tag-descendants h %))
(or (tag-descendants h tag) #{})))))
(testing "a tag isa? all of its parents"
(for-all [tag tags
:let [parents (parents h tag)]
parent parents]
(is (isa? h tag parent))))
(testing "a tag isa? all of its ancestors"
(for-all [tag tags
:let [ancestors (ancestors h tag)]
ancestor ancestors]
(is (isa? h tag ancestor))))
(testing "all my descendants have me as an ancestor"
(for-all [tag tags
:let [descendants (descendants h tag)]
descendant descendants]
(is (isa? h descendant tag))))
(testing "there are no cycles in parents"
(for-all [tag tags]
(is (not (contains? (transitive-closure tag #(parents h %)) tag)))))
(testing "there are no cycles in descendants"
(for-all [tag tags]
(is (not (contains? (descendants h tag) tag)))))))
assertとついていますが、やっているテスト自体は他とそこまで変わらないはずです。
ただ、このファイルの中では核となる内容をテストしていて、そういう意味でassertという名前を当てたのかなという気もしなくもないです、
ループの仕方や変数の共有の仕方が若干独特で面白いのと、こういうヘルパーの使い方、テストの置き方ができるんだというところが興味深いです。
テストの共通化
(defmacro deftest-both [txt & ises]
`(do
(deftest ~(symbol (str "For-" txt)) ~@ises)
(deftest ~(symbol (str "Doseq-" txt))
~@(map (fn [[x-is [x-= [x-for binds body] value]]]
(when (and (= x-is 'is) (= x-= '=) (= x-for 'for))
`(is (= (let [acc# (atom [])]
(doseq ~binds (swap! acc# conj ~body))
@acc#)
~value))))
ises))))
件のforとdoseqをまとめてテストしているロジックです。
先に見たテストに似て、同じ仕様であるならば似たようなテストは書かないというClojurianの矜持が感じられます。
例外処理
原因をたどる
(defn causes
[^Throwable throwable]
(loop [causes []
t throwable]
(if t (recur (conj causes t) (.getCause t)) causes)))
シンプルな再帰でcauseをたどってかき集めているだけですね。あまり解説はいらなさそうです。
例外を投げる
(defn exception
"Use this function to ensure that execution of a program doesn't
reach certain point."
[]
(throw (new Exception "Exception which should never occur")))
気持ち、わかりやすい例外を投げるだけです。地味に使用頻度が高いです。
例外を検証する
(defn a-match? [re s] (not (nil? (re-matches re s))))
(defmacro throws-with-msg
([re form] `(throws-with-msg ~re ~form Exception))
([re form x] `(throws-with-msg
~re
~form
~(if (instance? Exception x) x Exception)
~(if (instance? String x) x nil)))
([re form class msg]
`(let [ex# (try
~form
(catch ~class e# e#)
(catch Exception e#
(let [cause# (.getCause e#)]
(if (= ~class (class cause#)) cause# (throw e#)))))]
(is (a-match? ~re (.toString ex#))
(or ~msg
(str "Expected exception that matched " (pr-str ~re)
", but got exception with message: \"" ex#))))))
心なしかこのファイルだけ、テストの仕方が独特な気がします。
これは処理が欲しい型の例外が投げられるかどうかとその例外のエラーメッセージが正しいかどうかの検証でしょう。
シンプルな例外の検証はデフォルトであった気がしますが、カスタマイズしたいがために作ったのでしょうか。
背景は置いておいて例外をキャッチしてそのあとどのように処理するかを記した典型的なマクロでとてもわかりやすい、よいマクロだと思います。
例外も結果として受け取る
(defmacro return-exc [& forms]
`(try ~@forms (catch Throwable e# e#)))
そんなに使い道があるかしら。
Javaを扱う
プライベートフィールドへのアクセス
(defn get-field
"Access to private or protected field. field-name is a symbol or
keyword."
([klass field-name]
(get-field klass field-name nil))
([klass field-name inst]
(-> klass (.getDeclaredField (name field-name))
(doto (.setAccessible true))
(.get inst))))
あまりマナーはよくないですが、Clojureを使っているとたまに可視性をこじ開けているのを見ますね。
テストをするためにどうしても必要な時は使えそうです。
メソッド名の取得
(defn method-names
"return sorted list of method names on a class"
[c]
(->> (.getMethods c)
(map #(.getName %))
(sort)))
そのままですね。
testの拡張
reportのオーバーライド
;; This multimethod will override test/report
(defmulti ^:dynamic tap-report :type)
(defmethod tap-report :default [data]
(t/with-test-out
(print-tap-diagnostic (pr-str data))))
(defn print-diagnostics [data]
(when (seq t/*testing-contexts*)
(print-tap-diagnostic (t/testing-contexts-str)))
(when (:message data)
(print-tap-diagnostic (:message data)))
(print-tap-diagnostic (str "expected:" (pr-str (:expected data))))
(if (= :pass (:type data))
(print-tap-diagnostic (str " actual:" (pr-str (:actual data))))
(do
(print-tap-diagnostic
(str " actual:"
(with-out-str
(if (instance? Throwable (:actual data))
(stack/print-cause-trace (:actual data) t/*stack-trace-depth*)
(prn (:actual data)))))))))
(defmethod tap-report :pass [data]
(t/with-test-out
(t/inc-report-counter :pass)
(print-tap-pass (t/testing-vars-str data))
(print-diagnostics data)))
(defmethod tap-report :error [data]
(t/with-test-out
(t/inc-report-counter :error)
(print-tap-fail (t/testing-vars-str data))
(print-diagnostics data)))
(defmethod tap-report :fail [data]
(t/with-test-out
(t/inc-report-counter :fail)
(print-tap-fail (t/testing-vars-str data))
(print-diagnostics data)))
(defmethod tap-report :summary [data]
(t/with-test-out
(print-tap-plan (+ (:pass data) (:fail data) (:error data)))))
(defmacro with-tap-output
"Execute body with modified test reporting functions that produce
TAP output"
{:added "1.1"}
[& body]
`(binding [t/report tap-report]
~@body))
あやぴーさんの記事がとてもわかりやすいです。
これ以上わかりやすく書く自信がないので説明は省きますが、最後のbindingでマルチメソッドをすげかえているのはとても面白いなと思います。
まとめ
例外とかメタプログラミングとかは書いてみたはいいものの別に目新しい話でもなかったですね。
特徴的なのはbindingを動的に変えるところ、動的にテストを定義するところ、とかそういうあたりでしょうか。
あまりいろんな言語でテストを書いてきたわけではないので強くは言えないのですが、その場その場で最適なものを割り当ててコンテキストを変えていくという感じは独特な感じはしました。
ただ全体で通してみると、基本の構文で戦っているところが圧倒的に多かったです。
なので、当たり前のテストと少しの工夫がその哲学で、ひっかかったら直せばいいよというそんな感じの印象でした。
Clojureで多くをマクロでやっているライブラリは見ないので、やはりそこらへんが落としどころなのかなと。
というところで、ちょっと技術的な遊びに焦点を当てすぎた感じはするものの、一応自由は与えられているというところを感じて、今回は終わりにしようかと思います。