Muscle Assert for clojure.test

clojure.test の failure レポートを拡張するライブラリ、 muscle-assert を作りました。

clojure.test に対する不満

clojure.test は Clojure にバンドルされたユニットテスト用フレームワークです。

clojure.test に誰もが必ず感じるであろう不満として、 failure レポートが貧弱であるということが挙げられると思います。

(is (= {:foo 1} {:foo 2}) "should fail")

例えば上記のコードは次のような failure レポートを吐きだします。

FAIL in () (form-init1910383294713142383.clj:11)
should fail
expected: (= {:foo 1} {:foo 2})
  actual: (not (= {:foo 1} {:foo 2}))

少し見れば分かりますが、これは全く意味がないです。 expected に対して、 actual が not で expected の式を囲んだだけだからです。 普通に考えれば、 expected の逆(つまり not )だからテストが fail するのであって、これは何ひとつ情報が増えていない無駄な failure レポートです (強いていえば、 (is (= 1 (inc 1))) のようなフォームを評価すると (not (= 1 2)) と計算後の値が表示されるので意味がないことはないんですがその程度です)。

これが clojure.test に対する良くある不満のひとつです。

過去にこの問題を解決しようとした人達はいた

この不満点を Clojure が登場して数年も経つのにいつまでも放置しているほど Clojurian も馬鹿ではありませんでした。

幾つか紹介していきたいと思いますが、これから紹介していく出力例は以下のコードを評価したものとします。

(is (= {:widget {:debug :on
                 :window {:title "Sample Konfabulator Widget"
                          :name "main_windw"
                          :width 500
                          :height 500}
                 :image {:src "Images/Sun.png"
                         :name "sun1"
                         :hOffset 250
                         :vOffset 240
                         :alignment :center}
                 :text {:data "Click Here"
                        :size 36
                        :style "bold"
                        :name "text1"
                        :hOffset 250
                        :vOffset 110
                        :alignment :center
                        :onMouseUp "sun1.opacity = (sun1.opacity / 100) * 90;"}}}
       {:widget {:debug :on
                 :window {:title "Sample Konfabulator Widget"
                          :name "main_window"
                          :width 500
                          :height 500}
                 :image {:src "Images/Sun.png"
                         :name "sun1"
                         :hOffset 250
                         :vOffset 250
                         :alignment :center}
                 :text {:data "Click Here"
                        :size 36
                        :style "bold"
                        :name "text1"
                        :hOffset 250
                        :vOffset 100
                        :alignment :center
                        :onMouseUp "sun1.opacity = (sun1.opacity / 100) * 90;"}}}))

lein-difftest

これは Leiningen のプラグインとして実装されていて、次のような出力をします。

FAIL in () (core_test.clj:11)
expected: (= {:widget {:debug :on, :window {:title "Sample Konfabulator Widget", :name "main_windw", :width 500, :height 500}, :image {:src "Images/Sun.png", :name "sun1", :hOffset 250, :vOffset 240, :alignment :center}, :text {:data "Click Here", :size 36, :style "bold", :name "text1", :hOffset 250, :vOffset 110, :alignment :center, :onMouseUp "sun1.opacity = (sun1.opacity / 100) * 90;"}}} {:widget {:debug :on, :window {:title "Sample Konfabulator Widget", :name "main_window", :width 500, :height 500}, :image {:src "Images/Sun.png", :name "sun1", :hOffset 250, :vOffset 250, :alignment :center}, :text {:data "Click Here", :size 36, :style "bold", :name "text1", :hOffset 250, :vOffset 100, :alignment :center, :onMouseUp "sun1.opacity = (sun1.opacity / 100) * 90;"}}})
  actual:
   {:widget
    {:debug :on,
     :image
     {:alignment :center,
      :hOffset 250,
      :name "sun1",
      :src "Images/Sun.png",
      :vOffset 2
 - 5
 + 4
   0},
     :text
     {:alignment :center,
      :data "Click Here",
      :hOffset 250,
      :name "text1",
      :onMouseUp "sun1.opacity = (sun1.opacity / 100) * 90;",
      :size 36,
      :style "bold",
      :vOffset 1
 - 0
 + 1
   0},
     :window
     {:height 500,
      :name "main_wind
 - o
   w",
      :title "Sample Konfabulator Widget",
      :width 500}}}

文字列化して、文字列として diff を取るというやり方ですね。

humane-test-output

このライブラリを利用すると次のような出力をします。

FAIL in () (form-init711304879726972596.clj:11)
expected: {:widget
           {:debug :on,
            :window
            {:title "Sample Konfabulator Widget",
             :name "main_windw",
             :width 500,
             :height 500},
            :image
            {:src "Images/Sun.png",
             :name "sun1",
             :hOffset 250,
             :vOffset 240,
             :alignment :center},
            :text
            {:data "Click Here",
             :size 36,
             :style "bold",
             :name "text1",
             :hOffset 250,
             :vOffset 110,
             :alignment :center,
             :onMouseUp "sun1.opacity = (sun1.opacity / 100) * 90;"}}}
  actual: {:widget
           {:debug :on,
            :window
            {:title "Sample Konfabulator Widget",
             :name "main_window",
             :width 500,
             :height 500},
            :image
            {:src "Images/Sun.png",
             :name "sun1",
             :hOffset 250,
             :vOffset 250,
             :alignment :center},
            :text
            {:data "Click Here",
             :size 36,
             :style "bold",
             :name "text1",
             :hOffset 250,
             :vOffset 100,
             :alignment :center,
             :onMouseUp "sun1.opacity = (sun1.opacity / 100) * 90;"}}}
    diff: - {:widget
             {:text {:vOffset 110},
              :image {:vOffset 240},
              :window {:name "main_windw"}}}
          + {:widget
             {:text {:vOffset 100},
              :image {:vOffset 250},
              :window {:name "main_window"}}}

expected と actual が pprint したような見え方になっていて、最後に diff の結果が付いています。

なんとなく違う感

どんな形にしても diff が出るのはある程度便利です。

ですが、このようなやり方をすればある程度以上に大きな出力を比較したときに diff の結果が一瞬で使い物にならなくなるのは想像に難くないですし、 単純に clojure.data/diff の出力結果を利用していたりするので、 clojure.data/diff 特有のノイズが目に入ってきたりします。

(clojure.data/diff [1 2 3] [1 3])
;; [[nil 2 3] [nil 3] [1]]

このようにシーケンス同士を比較すれば同値のインデックス部分は nil で埋められてしまいます。 些細な問題ですが、これで満足していいのかは疑問が残るところです。

Muscle Assert

なんとかならないかなあと考えていたところに Muscle Assert なるものを実装したという話が流れてきました。

Persimmon用アサーションライブラリMuscleAssertを作った

これを読んだとき、まさしく Clojure のテストで必要だったものだと思いました。詳しくはこの記事と次の記事を読んでもらえればいいかと思います。

続・そろそろPower Assertについてひとこと言っておくか

Clojure を書く際に関数の引数と戻り値には単純なマップやベクタを利用することが多く、テストを書くときに expected をマップやベクタのリテラルで書くことは非常に多いです。

そして Clojure 版の Mascle Assert では先程のテストは次のように出力されます。

FAIL in () (core_test.clj:10)
left: {:widget {:debug :on, :window {:title "Sample Konfabulator Widget", :name "main_windw", :width 500, :height 500}, :image {:src "Images/Sun.png", :name "sun1", :hOffset 250, :vOffset 240, :alignment :center}, :text {:data "Click Here", :size 36, :style "bold", :name "text1", :hOffset 250, :vOffset 110, :alignment :center, :onMouseUp "sun1.opacity = (sun1.opacity / 100) * 90;"}}}
right: {:widget {:debug :on, :window {:title "Sample Konfabulator Widget", :name "main_window", :width 500, :height 500}, :image {:src "Images/Sun.png", :name "sun1", :hOffset 250, :vOffset 250, :alignment :center}, :text {:data "Click Here", :size 36, :style "bold", :name "text1", :hOffset 250, :vOffset 100, :alignment :center, :onMouseUp "sun1.opacity = (sun1.opacity / 100) * 90;"}}}
   in [:widget :window :name]
      left "main_windw"
     right "main_window"
   ----- diff details -----
   @@ -2,9 +2,10 @@
    ain_wind
   +o
    w
   in [:widget :image :vOffset]
      left 240
     right 250
   in [:widget :text :vOffset]
      left 110
     right 100

何処がどう違うのか、というのが一目瞭然ですね。

実は同じことをしているライブラリが存在した

flare というライブラリが実は muscle-assert と似たようなことをしています。

FAIL in () (core_test.clj:9)
expected: (= {:widget {:debug :on, :window {:title "Sample Konfabulator Widget", :name "main_windw", :width 500, :height 500}, :image {:src "Images/Sun.png", :name "sun1", :hOffset 250, :vOffset 240, :alignment :center}, :text {:data "Click Here", :size 36, :style "bold", :name "text1", :hOffset 250, :vOffset 110, :alignment :center, :onMouseUp "sun1.opacity = (sun1.opacity / 100) * 90;"}}} {:widget {:debug :on, :window {:title "Sample Konfabulator Widget", :name "main_window", :width 500, :height 500}, :image {:src "Images/Sun.png", :name "sun1", :hOffset 250, :vOffset 250, :alignment :center}, :text {:data "Click Here", :size 36, :style "bold", :name "text1", :hOffset 250, :vOffset 100, :alignment :center, :onMouseUp "sun1.opacity = (sun1.opacity / 100) * 90;"}}})
  actual: (not (=== {:widget {:debug :on, :window {:title "Sample Konfabulator Widget", :name "main_windw", :width 500, :height 500}, :image {:src "Images/Sun.png", :name "sun1", :hOffset 250, :vOffset 240, :alignment :center}, :text {:data "Click Here", :size 36, :style "bold", :name "text1", :hOffset 250, :vOffset 110, :alignment :center, :onMouseUp "sun1.opacity = (sun1.opacity / 100) * 90;"}}} {:widget {:debug :on, :window {:title "Sample Konfabulator Widget", :name "main_window", :width 500, :height 500}, :image {:src "Images/Sun.png", :name "sun1", :hOffset 250, :vOffset 250, :alignment :center}, :text {:data "Click Here", :size 36, :style "bold", :name "text1", :hOffset 250, :vOffset 100, :alignment :center, :onMouseUp "sun1.opacity = (sun1.opacity / 100) * 90;"}}}))

in [:widget :image :vOffset] expected 250, was 240
in [:widget :text :vOffset] expected 100, was 110
in [:widget :window :name]
  strings have 1 difference (90% similarity)
  expected: "main_wind(o)w"
  actual:   "main_wind(-)w"

作ってる途中で気がついたんですが、まあいっかなと思って実装してしまいました。 出力の仕方が違うとかそのあたりは趣味の問題だと思うので、 flare がやってないことをどんどん実装していきたいなと思っています。

最後に

Clojure のテスト周りは色々とまだ改善できる余地があると思っているので、今後も少しずつ色んなライブラリを実装していきたいと思います。 あと、良かったら muscle-assert を使って要望など貰えると喜ぶかもしれません。

Merry Christmas 🎄