最近、指先ノハクが好きでずっと聴いてます。あやぴーです。
ということで clojure.test の実用的な話です。"実用的"というとやはり"実務でも利用できるようプロジェクトにフィットするように改造していく"という意味だと思うので、今回は clojure.test の拡張方法について書いていきます。
clojure.test とは
まず、最初に clojure.test とはなんでしたっけという話ですが、皆さんもご存知の通り Clojure にバンドルされているユニットテスト用フレームワークです。
一般的に良く使われているマクロは deftest
, testing
, is
, are
あたりでしょうか?
簡単に改めて使い方を見ておくと
(deftest my-first-test
(is (= 1 (inc 0))))
(deftest my-second-test
(are [x y] (= x (inc y))
1 0
2 1
3 2))
(deftest my-failure-test
(is (thrown?
clojure.lang.ExceptionInfo
(throw (ex-info "WTF!!" {:foo 1})))))
こんなところでしょうか?だいたい良いと思うので拡張方法について話していきましょう。
clojure.test の拡張とは?
clojure.test が拡張できることは拡張性の高い Clojure においては何の不思議もないことですが、一体何を拡張するというのでしょうか。
clojure.test の ns doc を参照すれば、次のような文章が見つかります。
EXTENDING TEST-IS (ADVANCED)
You can extend the behavior of the ïs" macro by defining new methods for the ässert-expr" multimethod. These methods are called during expansion of the ïs" macro, so they should return quoted forms to be evaluated.
You can plug in your own test-reporting framework by rebinding the report" function: (report event)
The 'event' argument is a map. It will always have a :type key, whose value will be a keyword signaling the type of event being reported. Standard events with :type value of :pass, :fail, and :error are called when an assertion passes, fails, and throws an exception, respectively. In that case, the event will also have the following keys:
:expected The form that was expected to be true :actual A form representing what actually occurred :message The string message given as an argument to 'is'
The testing" strings will be a list in testing-contexts*, and the vars being tested will be a list in testing-vars*.
Your report" function should wrap any printing calls in the with-test-out" macro, which rebinds out to the current value of test-out.
For additional event types, see the examples in the code.
つまり、僕らは is
マクロの振舞いを変更する機会と report
関数を自分が使いたいものに変更する機会が残されているわけです (極端な話をすれば、拡張する機会がなくても拡張してしまうことが可能だったりするわけですけど)。
is マクロの振舞いを拡張する
普段 (is (thrown? clojure.lang.ExceptionInfo (throw (ex-info "WTF!!" {:foo 1}))))
のようなコードを書いていますが、 これは既に is
マクロの拡張された振舞いです。
ちょっと意味が分からないですよね。実際の clojure.test のコードを参照してみます。
(defmulti assert-expr
(fn [msg form]
(cond
(nil? form) :always-fail
(seq? form) (first form)
:else :default)))
;;; 中略...
(defmethod assert-expr 'thrown? [msg form]
;; (is (thrown? c expr))
;; Asserts that evaluating expr throws an exception of class c.
;; Returns the exception thrown.
(let [klass (second form)
body (nthnext form 2)]
`(try ~@body
(do-report {:type :fail, :message ~msg,
:expected '~form, :actual nil})
(catch ~klass e#
(do-report {:type :pass, :message ~msg,
:expected '~form, :actual e#})
e#))))
is
マクロを辿っていくと assert-expr
というマルチメソッドが定義してあり、 form
がシーケンスであればそれの先頭の値でディスパッチ しています。例外をテストするときに利用する thrown?
というシンボルはこのように実装されているというわけですね(例外のテストなので ちゃんと try
して catch
されればテストがパスするという風に書かれています)。
これに倣ってちょっとした振舞いを追加してみましょう。
(require '[clojure.test :as t])
(defmethod t/assert-expr 'fix-in-the-future [msg form]
`(t/do-report {:type :pass :message ~(or msg "Fix in the future")}))
これはまだテストが通らないけど、とりあえず将来的にテストを通す予定のものを書いておくためのものという感じです。テストは常にパスします。
次のように使います。
(deftest my-fix-in-the-future-test
(is (fix-in-the-future (= 1 2))))
これで is
マクロの振舞いを拡張することが出来ました。このように assert-expr
を拡張しているライブラリとしては fudje があります。
is マクロをラップした DSL を構築する
これは上述の方法とはやや違っていて、 is
マクロを更にマクロでラップして独自の DSL を構築してしまうという試みです。 この手法の代表格は are
マクロです。
さて、例えば JUnit4 くらいの時代に Java をやってきた方でしたら assertEquals
というメソッドをご存知だと思いますが、 (assert-equals expected actual)
という風に表現出来るマクロを書いてみます。
(require '[clojure.test :as t])
(defmacro assert-equals [expected actual]
`(t/is (~'= ~expected ~actual)))
簡単ですね。これは実際次のように使えてちゃんと動作します。
(deftest my-assert-fn
(assert-equals 1 (inc 1)))
;; Fail in my-assert-fn
;; expected: (= 1 (inc 1))
;; actual: (not (= 1 2))
ちなみに are
マクロはもうちょっとだけ複雑で、 clojure.template のマクロを呼び出して is
マクロへと展開しています。 このように DSL を構築しているライブラリは例えば iota や kerodon などがあります。
report 関数の振舞いを拡張する
上述した assert-expr
の拡張で fix-in-the-future
を追加しましたが、 do-report
に渡していたマップの :type
は :pass
でした。
(defmethod t/assert-expr 'fix-in-the-future [msg form]
`(t/do-report {:type :pass :message ~(or msg "Fix in the future")}))
このままではテストが 1 件成功していると見做されるため、 fix-in-the-future
というよりは always-pass
になってしまいます。
これをどうにかするために report
の振舞いを増やしましょう。次のように増やします。
(require '[clojure.test :as t])
(defmethod t/report :fixme [m]
(t/with-test-out
(t/inc-report-counter :fixme)
(println "\nFIXME in" (t/testing-vars-str m))
(when (seq *testing-contexts*) (println (testing-contexts-str)))
(when-let [message (:message m)] (println message))))
それから、 fix-in-the-future
の方も修正しておきます。
(defmethod t/assert-expr 'fix-in-the-future [msg form]
`(t/do-report {:type :fixme :message ~(or msg "Fix in the future")}))
こうしたことでテストの結果に、将来修正しないといけないものが反映されるようになりました。
これを実際に使ったものをテストとして実行すると次のようになります。
(t/run-tests 'demo.core-test)
;; {:test 11, :pass 6, :fail 5, :error 0, :fixme 2, :type :summary}
このように report
を拡張しているライブラリとしては clojure.test.check や bolth などがあります。
自分でテストランナーを実装する
…には流石に紙面と時間が足りないでやめておきましょう。 clojure.test それ自身のテストランナーを読めばなんとなく実装できそうだと思うはずです。
独自のテストランナーを完全に実装しているのは bolth か eftest だけだと思います( Midje や speclj はそもそもの方向性が違う気がしているので除外)。
最後に
今回、何故 clojure.test の拡張にフォーカスしたかというと、 Midje を使うのをやめたいなーと思っているからです(現在、弊社のプロジェクトでは Midje で多くのテストが書かれています)。 他にも clojure.test の実装を読まなければならない事情があったりして丁度良かったというのもありますが。
ここ最近の Clojure コミュニティを見ていると clojure.test.check を使っていこうという機運が熟してきているように思います。 その背景には clojure.spec などの登場がありますが、このような clojure.test と互換のある単機能のライブラリを使いたくても Midje や speclj のような 独立したテストフレームワークを使っていると利用することが出来ません(あるいは難しい)。 (これは完全に余談になるけど、 Midje のテストランナーは clojure.test の deftest
などを使っていても、それはそれとして別で実行してくれるので 現実的には両方使い続けることもできます / speclj は deftest
を拾ってはくれないようです)
なので、独立したオレオレテストフレームワークを使うよりは、 clojure.test と互換のあるようなものを作ったり、拡張できるところを拡張していく方が 良いのではないかと思って今回の記事を書きました。これを読んでいる皆さんが良い感じの clojure.test と互換性のあるテスト用のライブラリを書いてくれると僕は嬉しいです。
ちなみに、完全に clojure.test と互換性のある独自のレポーターを作成するのは地味に面倒で、有名なテストランナーですらまともに実装できていなかったりします( 単なる作業漏れなのか考慮漏れなのかは知りませんが、まだ僕も issue 立ててないので興味ある方は探してみると案外簡単に見つけることができるかもしれません)。
というわけで clojure.test の拡張について、でした。