Clojureアプリケーションで外界との境界をどう書くか
一般的なアプリケーションを作っている場合、外部のシステムというのは必ず存在しています。それはミドルウェアの形をしていたり、連携しないといけないAPIの形をしていたり様々な形で私たちの目の前に現れます。
このときに役立つのがboundariesという考え方です。文字通り境界ということです。
ちなみにこの考え方を取り込もうとした話は、一度書いたのですがそのときはプログラムを半自動生成したところに主眼を置いたのであまり説明していませんでした。
See also:
平たく言えば、プロトコルを使うことによって開発やテストのときに実装を差し替えたり、あるいは外界のシステムが変わって実装を変えないといけないとしても、プロトコルに依存している側は修正せずに済むよ、という感じの話です。 まあそんなことは分かっている方のほうが多いとは思うので、具体的な実装の話をしていきましょう。
外界との境界を実装する
例えば、データベースに接続するシステムを作っているときに、ユーザーを取得するための関数を作りたいとしましょう。普通に書くなら、以下のように書くと思います。 code:clojure
これはただの関数です。ただし、この関数はデータベースに依存します。そのためこの関数を実行するためには、必ずデータベースへの接続情報を渡さないといけないですし、実際に接続可能なデータベースを必要とします。
つまり、この関数は外界との境界に存在しているわけなんですが、このままではこの関数に依存する関数のテストを行うのに大変不便な思いをします。
例えば、実データベースをテスト時に利用する場合、テスト用のデータベースが必要になりますし、テストケース毎にまっさらな状態を作り直って、さらにテストデータを登録する必要があるため、このあたりの処理が煩雑になりやすいです。
------------------------
ちなみに実データベースで高速にテストを実行する話は昔書きました。
See also:
------------------------
そこで、まずは外界のモノであるデータベースをアプリケーションとの境界を越えたモノとして定義しましょう。ここではDuctの語彙をそのまま使ってSQLBoundaryという名前にしておきましょう。
code:clojure
簡単ですね。SQLBoundaryはただ接続情報を格納するためのモノだと思ってください(場合によってはコネクションプールになることもあると思います)。
次に get-user を実装しますがアプリケーションからは外界のモノに直接依存しないようにしたいため、プロトコルを使います。
code:clojure
(defprotocol UserDatabase
このようなプロトコルを作っておけば、これを利用する側はUserDatabaseプロトコルに依存するだけでよくなり、実装の詳細に依存しないで済むようになります。
実装の詳細は次のように書けます。
code:clojure
(extend-protocol UserDatabase
SQLBoundary
こうしておけばこのように利用することができるようになります。
code:clojure
(let [db (map->SQLBoundary {:db-spec {:dbtype "postgresql"
:dbname "mydatabase"
:user "postgres"}})]
(get-user db "ayato-p"))
;; => {:username "ayato-p"}
これだと何がどう嬉しくなったのか見た目で分かり難いですが、テストを書いてみれば分かります。
外界との境界をモックしてテストする
例えば実データベースを利用する場合は、前述のように直接SQLBoundaryを利用すればよいですが、そうしたくないこともあると思います。テストが遅いとか、適当な値を返してくれればいいとか、まあそんな感じです。そういうときは以下のようにreifyを使ってモックしてしまうことにします。 code:clojure
(def simple-user-database-mock
(let [database (reduce (fn [m {:keys username :as user}] (assoc m username user))
{}
[{:username "ayato-p"}
{:username "athos"}])]
(reify sut/UserDatabase
(get-user username
(get database username)))))
sutは上のコードが定義してあるテスト対象のネームスペースの別名と思ってください。こういうモックを作っておくとテストを以下のように書くことができます。
code:clojure
(t/deftest simple-mocking-test
(t/is (= (sut/get-user simple-user-database-mock "athos")
{:username "athos"}))
(t/is (= (sut/get-user simple-user-database-mock "ayato-p")
{:username "ayato-p"})))
また、このようなことをしたくなることは多いので、それ専用のライブラリがあります。
code:clojure
(t/deftest shrubbery-stub-test
(let [user-database-stub (shrubbery/stub
sut/UserDatabase
{:get-user {:username "ayato-p"}})]
(t/is (= (sut/get-user user-database-stub "ayato-p")
{:username "ayato-p"}))))
(t/deftest shrubbery-mock-test
(let [user-database-mock (shrubbery/mock
sut/UserDatabase
{:get-user {:username "ayato-p"}})]
(t/is (not (shrubbery/received? user-database-mock sut/get-user)))
(t/is (= (sut/get-user user-database-mock "ayato-p")
{:username "ayato-p"}))
(t/is (= (shrubbery/call-count user-database-mock sut/get-user) 1))
(t/is (shrubbery/received? user-database-mock sut/get-user))
(t/is (shrubbery/received? user-database-mock sut/get-user "ayato-p")) (t/is (not (shrubbery/received? user-database-mock sut/get-user "athos"))))) reifyのような複雑な実装を渡すことはできませんが、引数のチェックや何度関数が呼び出されたかなどのチェックができます。このようなテストを書きたいケースはとても多いので重宝すると思います。
See also: