こんにちはこんにちは!!あやぴーです。 最近の仕事ではF#を使ったり、Clojureを使ったりすることが多いのですが、今日は久しぶりにClojureの話を書きます。
Clojureでテスト書くときに一緒に使うライブラリ何使ってますか?
アプリケーションでユニットテストレベルのテストを書くとき、どういうライブラリを一緒に使っていますか?Kaocha?Midje?test.check?shrubbery?Flare?
今回は最近いくつかのプロジェクトで使っていて使い勝手がよかったものを2つ紹介しようと思っています。
テスト対象が依存する関数をモックしたい - mockfn
Clojureはいわゆる関数プログラミングを行うことができる言語で、オブジェクト指向言語に見られるような、オブジェクト生成時に外部から依存するオブジェクトをインジェクションして、テストをしやすいようにするというような実装を行うことはあまり多くありません。基本的には関数の中で関数を呼び出す、というような単純な実装が多くなります。より具体的に言えば、以下のfind
がfind-articles
を呼び出しているように書くことがあります。尚、例示のために諸々簡易化したコードを書いています。
(ns my.awesome.app.usecase.find-published-articles (:require [my.awesome.app.infra.article :refer [find-articles]])) (defn find [user] (letfn [(published? [{:keys [status]}] (= :published status))] (->> (find-articles (:username user)) (filter published?))))
このfind
は任意のユーザの公開済み記事をすべて返却する関数です。内部的にはfind-articles
を使って任意のユーザの記事をすべて取得した後、それぞれの記事からステータスが公開済みになっているもののみを取り出すような実装になります。さて、このfind
に対してのテストを考えてみましょう。
(ns my.awesome.app.usecase.find-published-articles-test (:require [clojure.test :refer [deftest is testing]] [my.awesome.app.usecase.find-published-articles :as sut])) (deftest find-published-articles-test (testing "任意のユーザの公開された記事をすべて取得する" (is (= [{:title "Clojureが人気のたったひとつの理由" :status :published} {:titcle "すごいClojureたのしく学ぼう!" :status :published}] (sut/find {:username "ayato-p"})))))
まず必要なものだけ書くとこのような感じでしょうか。テストを実行すると当然コケます。
; Running tests for my.awesome.app.usecase.find-published-articles-test... ; FAIL in my.awesome.app.usecase.find-published-articles-test/find-published-articles-test (form-init8905866043945389713.clj:7): ; 任意のユーザの公開された記事をすべて取得する ; expected: [{:title "Clojureが人気のたったひとつの理由", :status :published} {:titcle "すごいClojureたのしく学ぼう!", :status :published}] ; actual: () ; 1 tests finished, problems found. 😭 errors: 0, failures: 1, ns: 1, vars: 1
当然ですが、find
がfind-articles
に依存しているため、find-articles
の返す結果次第でテストが通ったりコケたりすることになります。そのためにこのfind-articles
をモックしたい、というモチベーションに繋がってきます。任意の関数をモックする方法は昔からいろいろとあり、原始的なモックの仕方で言えばwith-redefs
を使うのが一般的でしょうか。Midjeやfudjeなどのライブラリを使うことでもモックすることができます。
今回紹介したいのはmockfnというNubankが開発/公開しているライブラリです。NubankはCognitectを買収したことで有名なClojureを使っている企業ですね。mockfnは名前の通り関数をモックするためのライブラリになります。このライブラリを利用すると次のように書くことができます。
(ns my.awesome.app.usecase.find-published-articles-test (:require [clojure.test :refer [deftest is testing]] [mockfn.macros :refer [providing]] [my.awesome.app.infra.article :refer [find-articles]] [my.awesome.app.usecase.find-published-articles :as sut])) (deftest find-published-articles-test (testing "任意のユーザの公開された記事をすべて取得する" (providing [(find-articles "ayato-p") [{:title "Clojureが人気のたったひとつの理由" :status :published} {:title "Clojureめっちゃすごい" :status :draft} {:titcle "すごいClojureたのしく学ぼう!" :status :published}]] (is (= [{:title "Clojureが人気のたったひとつの理由" :status :published} {:titcle "すごいClojureたのしく学ぼう!" :status :published}] (sut/find {:username "ayato-p"}))))))
あまりwith-redefs
を使う場合と変わらないように見えますが、このmockfnが優れている点は2つあります。まずひとつ目ですが、同じ関数を2回以上違う引数で呼び出す例を素直に書けます。これは通常with-redefs
やfudjeなどを使うとうまく書くのにコツが必要でした。それが次の例に見れるように素直に書き下すことができます。
(defn f [x] (inc x)) (providing [(f 10) 9 (f 2) 42] (is (= 9 (f 10))) (is (= 42 (f 2))))
ふたつ目にmockfnが提供するproviding
マクロとは別にverifying
というマクロが用意されており、こちらを用いると関数が呼び出されたか、何回呼び出されたかなどもチェックすることができます。次の例では引数として"ayato-p"
を渡す場合が確実に1度呼び出されたことを確認しつつ、"hogefuga"
を渡して呼び出されたことがないことを確認しています。繰り返しになりますが、例示のためのコードです。
(ns my.awesome.app.usecase.find-published-articles-test (:require [clojure.test :refer [deftest is testing]] [mockfn.macros :refer [verifying]] [mockfn.matchers :refer [exactly]] [my.awesome.app.infra.article :refer [find-articles]] [my.awesome.app.usecase.find-published-articles :as sut])) (deftest find-published-articles-test (testing "任意のユーザの公開された記事をすべて取得する" (verifying [(find-articles "ayato-p") [{:title "Clojureが人気のたったひとつの理由" :status :published} {:title "Clojureめっちゃすごい" :status :draft} {:titcle "すごいClojureたのしく学ぼう!" :status :published}] (exactly 1) (find-articles "hogefuga") [] (exactly 0)] (is (= [{:title "Clojureが人気のたったひとつの理由" :status :published} {:titcle "すごいClojureたのしく学ぼう!" :status :published}] (sut/find {:username "ayato-p"}))))))
素晴らしいですね。mockfnはwith-redefs
を利用しているため、with-redefs
でできることと同等のことができます。そのため、Protocolに対するモックを行うこともできます。ただし、モックした関数にProtocolを実装したオブジェクトを渡すとそちらを優先して呼び出してしまいモックに失敗してしまうので、利用するときは注意が必要です。
(defprotocol Foo (foo [this])) (providing [(foo mockfn.matchers/any-args?) 42] ;; `any-args?`を利用すると引数を気にしなくなります (= 42 (foo :x) (foo :y) (foo :z))) ;; => true (providing [(foo mockfn.matchers/any-args?) 42] (= 42 (foo (reify Foo (foo [_] 29))))) ;; => false
Protocolを実装したオブジェクトをモックしたい場合、shrubberyを利用することが多かったのですが、これからはmockfnでもいいかなと思いました。使い分けてもいいのですが、テストを統一的に書けるという点でmockfnのみ使うのは便利かなと思います。
複雑なデータ構造のdiffを簡単に知りたい(あるいはデータ構造の比較を簡単にやりたい) - matcher-combinators
この話は6年近く前にも自作のライブラリを作ったりしたことがあるんですが、一旦それは脇に置いておきます。Clojureはマップやベクタ、セットなどの基本的なデータ構造のみで関数の結果を作ることが多く、有名なRingでもリクエストハンドラーの結果は単純なマップとしています。
Clojureの世界観として、基本的なデータ構造のみで関数の結果を作ることは納得できると思いますが、テストを書くという文脈においてはこれが苦労する原因になっています。例えば先程のテストの例をもう一度見てみましょう。
(ns my.awesome.app.usecase.find-published-articles-test (:require [clojure.test :refer [deftest is testing]] [mockfn.macros :refer [providing]] [my.awesome.app.infra.article :refer [find-articles]] [my.awesome.app.usecase.find-published-articles :as sut])) (deftest find-published-articles-test (testing "任意のユーザの公開された記事をすべて取得する" (providing [(find-articles "ayato-p") [{:title "Clojureが人気のたったひとつの理由" :status :published} {:title "Clojureめっちゃすごい" :status :draft} {:titcle "すごいClojureたのしく学ぼう!" :status :published}]] (is (= [{:title "Clojureが人気のたったふたつの理由" :status :published} {:titcle "すごいClojureたのしく学ぼう!" :status :published}] (sut/find {:username "ayato-p"}))))))
is
マクロに注目してください。=
での比較なので期待値とfind
の結果が完全一致することを期待しています。実は上のテストは少し書き換えているので失敗するのですが、このときClojureのテストはどのようなメッセージを出力するのか見てみることにします。
; Running tests for my.awesome.app.usecase.find-published-articles-test... ; FAIL in my.awesome.app.usecase.find-published-articles-test/find-published-articles-test (form-init5144004708583571708.clj:20): ; 任意のユーザの公開された記事をすべて取得する ; expected: [{:title "Clojureが人気のたったふたつの理由", :status :published} {:titcle "すごいClojureたのしく学ぼう!", :status :published}] ; actual: ({:title "Clojureが人気のたったひとつの理由", :status :published} {:titcle "すごいClojureたのしく学ぼう!", :status :published}) ; 1 tests finished, problems found. 😭 errors: 0, failures: 1, ns: 1, vars: 1
何がどう違うのかひとめでは分かりません。これは昔からこういうものなので、いわゆる目grep力を鍛えるのが最も有効な手段となります。……冗談です。これをひと目で見破れるようにするのが今回紹介するmatcher-combinatorsです。こちらも先程のmockfnと同じくNubankが開発/公開しているライブラリになります。これまで似たようなコンセプトのライブラリは幾つかあったのですが、使い勝手はこれが1番いいのではないでしょうか。早速使ってみます。
(ns my.awesome.app.usecase.find-published-articles-test (:require [clojure.test :refer [deftest is testing]] [matcher-combinators.clj-test] [mockfn.macros :refer [providing]] [my.awesome.app.boundary.article :refer [find-articles]] [my.awesome.app.usecase.find-published-articles :as sut])) (deftest find-published-articles-test (testing "任意のユーザの公開された記事をすべて取得する" (providing [(find-articles "ayato-p") [{:title "Clojureが人気のたったひとつの理由" :status :published} {:title "Clojureめっちゃすごい" :status :draft} {:titcle "すごいClojureたのしく学ぼう!" :status :published}]] (is (match? [{:title "Clojureが人気のたったふたつの理由" :status :published} {:titcle "すごいClojureたのしく学ぼう!" :status :published}] (sut/find {:username "ayato-p"}))))))
何が変わったか分かりますか?ネームスペースにのrequireにmatcher-combinators.clj-test
を追加してis
マクロ中の=
をmatch?
と書き換えました。テストを実行して結果を見てみましょう。
; Running tests for my.awesome.app.usecase.find-published-articles-test... ; FAIL in my.awesome.app.usecase.find-published-articles-test/find-published-articles-test (form-init5144004708583571708.clj:18): ; 任意のユーザの公開された記事をすべて取得する ; expected: (match? [{:title "Clojureが人気のたったふたつの理由", :status :published} {:titcle "すごいClojureたのしく学ぼう!", :status :published}] (sut/find {:username "ayato-p"})) ; actual: ({:title (mismatch (expected Clojureが人気のたったふたつの理由) (actual Clojureが人気のたったひとつの理由)), :status :published} {:titcle すごいClojureたのしく学ぼう!, :status :published}) ; 1 tests finished, problems found. 😭 errors: 0, failures: 1, ns: 1, vars: 1
どうでしょうか。actualの部分がだいぶ見易くなったと思います。今回違ったのはひとつ目の要素の:title
に対応する値だったのですが、簡単に分かるようになりました。このmatch?
というのは以前に紹介したことがあるclojure.test
の拡張ポイントを使って拡張したものですね。
もう少しだけmatcher-combinatorsの機能を紹介したいので、find
の返す記事の一覧が順序について頓着しない場合、どのようにアサーションするかについて考えてみます。普通に書こうとするとちょっとめんどくさく感じますが、matcher-combinatorsを使う場合、次のように書くことができます。
(ns my.awesome.app.usecase.find-published-articles-test (:require [clojure.test :refer [deftest is testing]] [matcher-combinators.clj-test] [matcher-combinators.matchers :refer [in-any-order]] [mockfn.macros :refer [providing]] [my.awesome.app.boundary.article :refer [find-articles]] [my.awesome.app.usecase.find-published-articles :as sut])) (deftest find-published-articles-test (testing "任意のユーザの公開された記事をすべて取得する" (providing [(find-articles "ayato-p") [{:title "Clojureが人気のたったひとつの理由" :status :published} {:title "Clojureめっちゃすごい" :status :draft} {:titcle "すごいClojureたのしく学ぼう!" :status :published}]] (is (match? (in-any-order [{:title "すごいClojureたのしく学ぼう!" :status :published} {:title "Clojureが人気のたったひとつの理由" :status :published}]) (sut/find {:username "ayato-p"}))))))
簡単ですね。match?
は良い感じにデフォルトの挙動が設定されているため、例えば上記の例で記事のタイトルだけ一致していればいいなと思った場合、マップの中に:title
のみを残すことでその部分についてのみ比較をしてくれます。つまり、期待値側に存在しないキーは無視するということですね。他にもいろいろなテストに対応できるようになっているため、READMEをさらっと読んでみるとこのライブラリの素晴らしさがよく分かると思います。
余談ですが、clj-kondoを利用しているとmatch?
の部分がunresolved-symbol
といってけたたましく怒られるため以下のような設定をclj-kondoのconfig.ednに書いておくとよいでしょう。
{:linters {:unresolved-symbol {:exclude [(clojure.test/is [match?])]}}}
モックした関数が取る引数をスマートに制限したい - mockfn & matcher-combinators
最後にmockfnとmatcher-combinatorsを組み合わせて使う方法だけ簡単に紹介しておきます。これはmockfnのドキュメントにさらっと紹介されているだけなのですが、知っておくと便利なのでここでも言及しておきます。よくコードを書いていると、いくつかのキーが入ったマップを受け取る関数f
があり、f
は受け取ったマップをそのまま関数g
に渡すというようなコードを書くことが多々あります。そして、関数g
ではひとつのキーしか使わないため、そのキーが渡っていることさえ保証できていればよい、ということも多々あります。そんな関数f
をテストするときに使える書き方です。
(ns my.awesome.app.usecase.test (:require [clojure.test :refer [deftest is]] [matcher-combinators.standalone :refer [match?]] [mockfn.clj-test] [mockfn.macros :refer [providing]])) (defn g [{:keys [username]}] username) (defn f [m] (g m)) (deftest t (providing [(g (match? {:username "ayato-p"})) :mocked] (is (= :mocked (f {:username "ayato-p" :foo 1 :bar 2})))))
伝わったか分かりませんが、providing
マクロで関数g
をモックするさいに、マップの中に:username
があってその値が"ayato-p"
であれば:mocked
を返却するように書いています。この書き方は非常に便利なので知っておくとよいでしょう。
まとめ
今回、Nubankが公開しているmockfnとmatcher-combinatorsについて紹介しました。簡単に紹介しただけなので、それぞれのライブラリについて詳しく知りたければそれぞれのドキュメントをあたるようにしてください。これらのライブラリはテストを書くのに重宝すること間違いなしでしょう。それではよいClojureライフを〜。