<-- mermaid -->

最近Clojureでテストを書くときに使っているライブラリをふたつ紹介します

こんにちはこんにちは!!あやぴーです。 最近の仕事ではF#を使ったり、Clojureを使ったりすることが多いのですが、今日は久しぶりにClojureの話を書きます。

Clojureでテスト書くときに一緒に使うライブラリ何使ってますか?

アプリケーションでユニットテストレベルのテストを書くとき、どういうライブラリを一緒に使っていますか?KaochaMidjetest.checkshrubberyFlare

今回は最近いくつかのプロジェクトで使っていて使い勝手がよかったものを2つ紹介しようと思っています。

テスト対象が依存する関数をモックしたい - mockfn

Clojureはいわゆる関数プログラミングを行うことができる言語で、オブジェクト指向言語に見られるような、オブジェクト生成時に外部から依存するオブジェクトをインジェクションして、テストをしやすいようにするというような実装を行うことはあまり多くありません。基本的には関数の中で関数を呼び出す、というような単純な実装が多くなります。より具体的に言えば、以下のfindfind-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

当然ですが、findfind-articlesに依存しているため、find-articlesの返す結果次第でテストが通ったりコケたりすることになります。そのためにこのfind-articlesをモックしたい、というモチベーションに繋がってきます。任意の関数をモックする方法は昔からいろいろとあり、原始的なモックの仕方で言えばwith-redefsを使うのが一般的でしょうか。Midjefudjeなどのライブラリを使うことでもモックすることができます。

今回紹介したいのは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ライフを〜。

Page top