Integrantを使ったアプリケーションのテスト時にモックを活用するために
説明に使うアプリケーション
以下のコマンドで作ったテンプレートを元にアプリケーションを書いてます。
code:shell
lein new duct-beta blog --to-dir integrant-mock-demo -- +api +example +postgres
duct-betaなのでテンプレートのバージョンは0.11.0-beta4です。完全なサンプルはなんかそのうち公開すると思う(本当は書いて公開しようと思ったんだけど、疲れたので後でやる、きっと)。
この記事を書き始めたモチベーション
Integrantを使ってサーバーサイド(製品/サービスという意味で)を開発したことがなく、あまり真面目にテストの書き方を考えてこなかったのですが、最近Integrantなアプリケーションを書きはじめていて、改めて考えないといけないことがいくつかあることに気付きました。 マルチメソッドはグローバルでミュータブルなオブジェクト
ディスパッチ値さえ分かるなら、どのネームスペースからでも参照できるグローバルな値
モックは任意のテストケースの中でのみ有効であり、テストケースの外側では無効になってほしい
任意の機能をモックしたいだけで、毎回マルチメソッドを書くのは違和感がある
ディスパッチ値の親子関係を定義する必要がある
単に面倒というのもある
任意の実装が紐付いているキーを差し替えるのは、composite key形式ではできない
:x/parentに実装がある場合、[:x/parent :x.parent/child]をコンフィグマップに含めて、:x.parent/childに実装を紐付けてしまうと、Integrantはシステムを起動することができない だから、deriveしないといけなくなる
つまり、そのモックオブジェクトに感心があるのは、そのテストケースだけのはずなのでその中に閉じていてほしい、という極々自然な要求です。ほとんど場合、これは問題になりにくいです。具体的には、init-keyマルチメソッドを定義するときの、ディスパッチ値にテストネームスペースで修飾したキーワードを用いれば、モックオブジェクトを生成するコンポーネントが衝突することはほとんどありえないからです。とはいえ、これは慣習によって身を守っているだけであって、慣習を知らなければ身を守ることができません。また、やろうと思えば他のテストネームスペースから参照することも可能なので、安全とは言い難いです。
モックを書くためだけに毎回マルチメソッドにメソッドを追加するというのは少々面倒なので、このあたりをもう少しどうにかしたいです。
以下は普通にテストを書いたら、こうなってしまうという例。読んでもらえると分かりますが、deftestの前にモックコンポーネントを用意しています。また、任意のコンポーネントを差し替えるために、前処理(prep)を終えたコンフィグマップを操作しています。このあたりは完 全に定型処理です。
code:clojure
(ns blog.handler.draft-test
(defn prep-test-config []
(-> (io/resource "blog/config.edn")
duct/read-config
(defmethod ig/init-key ::mock-return-something _
(shr/mock usecase/DraftUsecase
{:get-index [{:id 1 :title "Clojureは最高の言語だ"}
{:id 2 :title "Clojureは素晴しい言語だ"}]}))
(derive ::mock-return-something :blog.usecase/draft)
(defmethod ig/init-key ::mock-return-empty-list _
(shr/mock usecase/DraftUsecase
{:get-index []}))
(derive ::mock-return-empty-list :blog.usecase/draft)
(t/deftest index-test
(t/testing "全ての下書き記事のidとタイトルを取得することができる"
(let [system (-> (prep-test-config)
(dissoc :blog.usecase/draft)
(assoc ::mock-return-something {})
handler (::sut/index system)
draft-usecase (val (ig/find-derived-1 system :blog.usecase/draft))]
(try
(t/is (= 200 status))
(t/is (= [{:id 1 :title "Clojureは最高の言語だ"}
{:id 2 :title "Clojureは素晴しい言語だ"}]
body)))
(t/is (= 1 (shr/call-count draft-usecase usecase/get-index)))
(finally
(ig/halt! system)))))
(t/testing "下書き記事がない場合、空のリストを取得することができる"
(let [system (-> (prep-test-config)
(dissoc :blog.usecase/draft)
(assoc ::mock-return-empty-list {})
handler (::sut/index system)
draft-usecase (val (ig/find-derived-1 system :blog.usecase/draft))]
(try
(t/is (= 200 status))
(t/is (= [] body)))
(t/is (= 1 (shr/call-count draft-usecase usecase/get-index)))
(finally
(ig/halt! system))))))
こうなったら嬉しい
とりあえず、こういう風になったら嬉しい。
code:clojure
(t/deftest index-test
(t/testing "全ての下書き記事のidとタイトルを取得することができる"
(with-test-system [handler ::sut/index
usecase :blog.usecase/draft]
{:blog.usecase/draft (fn []
(shr/mock usecase/DraftUsecase
{:get-index [{:id 1 :title "Clojureは最高の言語だ"}
{:id 2 :title "Clojureは素晴しい言語だ"}]}))}
(t/is (= 200 status))
(t/is (= [{:id 1 :title "Clojureは最高の言語だ"}
{:id 2 :title "Clojureは素晴しい言語だ"}]
body)))
(t/is (= 1 (shr/call-count usecase usecase/get-index)))))
(t/testing "下書き記事がない場合、空のベクタを取得することができる"
(with-test-system [handler ::sut/index
usecase :blog.usecase/draft]
{:blog.usecase/draft (fn []
(shr/mock usecase/DraftUsecase
{:get-index []}))}
(t/is (= 200 status))
(t/is (= [] body)))
(t/is (= 1 (shr/call-count usecase usecase/get-index))))))
わりと空想ベースでwith-test-systemというマクロがあるかのように書いてみた。空想で書いているからあれだけど、こんな感じであまりIntegrantのモックコンポーネントを作っている、という意識をしなくて済むようになれば楽だと思うし、with-test-systemの中でモックコンポーネントが勝手に消滅してくれれば嬉しいかなと。 というわけで作った
ここまで書いて、よく考えたら一般化できることに気がついたので、bulkheadというライブラリを作ってみた(この間、実に1週間以上かかっている)。とりあえず、突貫でコードだけ書いた状態なので、諸々整備してない(READMEやCIなど)ですがこんな感じで使える(はずの)ライブラリです。 code:clojure
(ns blog.handler.draft-test
(defn prep-test-config []
(-> (io/resource "blog/config.edn")
duct/read-config
(b/set-prep! prep-test-config)
(t/deftest index-test
(t/testing "全ての下書き記事のidとタイトルを取得することができる"
(b/with-bulkhead [handler ::sut/index
draft-usecase :blog.usecase/draft]
{:blog.usecase/draft (constantly
(shr/mock usecase/DraftUsecase
{:get-index [{:id 1 :title "Clojureは最高の言語だ"}
{:id 2 :title "Clojureは素晴しい言語だ"}]}))}
(t/is (= 200 status))
(t/is (= [{:id 1 :title "Clojureは最高の言語だ"}
{:id 2 :title "Clojureは素晴しい言語だ"}]
body)))
(t/is (= 1 (shr/call-count draft-usecase usecase/get-index)))))
(t/testing "下書き記事がない場合、空のリストを取得することができる"
(b/with-bulkhead [handler ::sut/index
draft-usecase :blog.usecase/draft]
{:blog.usecase/draft (constantly
(shr/mock usecase/DraftUsecase
{:get-index []}))}
(t/is (= 200 status))
(t/is (= [] body)))
(t/is (= 1 (shr/call-count draft-usecase usecase/get-index))))))
最初の例に比べると比較的シュッとしています。このbulkheadが提供しているのはwith-bulkheadというマクロとset-prep!という関数だけです。 with-bulkheadの第1引数で任意のコンポーネントを束縛することができて、左辺にシンボル、右辺にコンポーネントのキーを指定することができます。これは自動的に起動したシステムから、右辺で指定したキーに対応するコンポーネントを自動的に取り出して、左辺のシンボルを束縛します。第2引数でモックしたい対象とモックを作成するコンストラクタを指定することができます。あとはいわゆるボディ部なので、色々書くことができます。ほぼ間違いなくすべてのテストで必要になるであろう、システムの起動と停止、任意のコンポーネントだけを起動するような処理は自動的に行なっています。
bulkheadで書いたコードの優れている点は、Integrantのお作法や細かい挙動を把握していなくても、ある程度直感的にモックを作成して利用できる点にあります。今回の例だと:blog.usecase/draftというキーに対応するコンポーネントをモックしているわけですが、差し替える側では新しいキーを自分で作る必要も、deriveする必要もなく、ただなんとなくこのキーを差し替えたい、という書き方ができます。マクロを展開すると分かりますが、裏では新しいキーを自動で生成し、差し替えるということを行っていたりします。 他にもいくつか特筆すべき特徴があるはあるんですが、とりあえずこういう風にシュッと書けると嬉しいのではないでしょうか、という話でした。いくつかの例はテストに書いてあるので、適当に参照してみてください(READMEすらないライブラリ…)。
というわけで、穴埋めらしい軽い記事でした。詳しい話は来年のLisp Meetupあたりでやるかもしれないし、やらないかもしれないです。