What is it, naokirin?

Clojureでテストを書く (with TDD)

Clojureでテストを書く、というのはclojure.testというものを用いることで簡単に達成できます。

今回はTDDを進めていく体で、テストの書き方を紹介していこうと思います。
お題は簡単に行うためにFizzBuzz*1です。FizzBuzzについてはWeb上に多く紹介があると思いますので割愛させていただきます。

これからコードを書いていきますが、コードはdiffのように追加した行は"+"、削除された行は"-"をつけることにします。

TDD最初の一歩とisマクロ

TDDの最初は、「まずはテストから」ですよね。

+(ns fizzbuzz
+  (:use clojure.test))
+
+(is (= 1 (say 1)))

ここでは"say"と言う関数が、引数に1を受け取ると1が期待されるということを書いています。

これを実行すると

java.lang.Exception: Unable to resolve symbol: say in this context

と言った風に出るはずです。最初のテストが失敗すること(Red)の確認は成功したみたいですね。

ここでClojure最初のテストコードとして"is"というが出てきました。
この"is"の動作については少しあとで書きますのでもう少し待ってください。

次にFake It(仮実装)です。テストが通る最小限のコードを書いてみます。

 (ns fizzbuzz
   (:use clojure.test))

+(defn say [n] 1)

 (is (= 1 (say 1)))

もう一度、実行してみます。

…何も表示されませんね。
おそらくreplなどで実行すると、"is"からtrueが返ってくると思います。

ひとまず、テストは成功、Greenになったようです。
テスト結果の表示がないのが気がかりですが、そのまま引数に2を渡した時についてもテストしてみましょう。

テストから、ですよね。

 (ns fizzbuzz
   (:use clojure.test))

 (defn say [n] 1)

 (is (= 1 (say 1)))
+(is (= 2 (say 2)))

さて、実行してみましょう。

FAIL in clojure.lang.PersistentList$EmptyList@1
expected: (= 2 (say 2))
  actual: (not (= 2 1))

今回はテスト結果がちゃんと表示されました。Redです。
さらに同じようなコードをreplで実行すると、falseが返ってくることが分かります。

ここまでで、"is"というのが、

  • 渡された式の結果がtrueなら、何もせずtrueを返す
  • 渡された式の結果がfalseなら、失敗したこと、そして期待された結果と実際の結果を表示する

という動作をするマクロであることが分かりました。

パラメタライズドテストとareマクロ

さて、先ほどRedになっていたので、早速実装を修正してGreenにしてしまいましょう。
もう2つのデータでテストしているので、三角測量で実装を行ってしまいましょう。

 (ns fizzbuzz
   (:use clojure.test))

+(defn say [n] n)

 (is (= 1 (say 1)))
 (is (= 2 (say 2)))

これでテストは成功するはずです。


まだテストは2つですが見た目としてテストが重複しているのが気になるところです。
早計ですが、まとめてしまいたいところです。

複数のデータを一括してテストするにはどうすればいいでしょう。
そこで中学の英語の授業で習ったように"is"の複数形、"are"を使ってみましょう。

 (ns fizzbuzz
   (:use clojure.test))

 (defn say [n] n)

+(are [n] (= n (say n))
+  1
+  2)

と書きなおしてみます。
すると、ちゃんと成功します。

もっとも、パラメタ(今はnだけ)は複数あってもかまいません。

(are [n m] (= 1 (+ n m))
  0 1
  1 0
  0.5 0.5)

テストコードの重複が現れたら、"are"マクロも使ってみましょう。

BDD(Behaviour Driven Development)とdeftestマクロおよびtestingマクロ

まだまだ、仕様はたくさんありますから、どんどんテストを追加して実装していきましょう。

 (ns fizzbuzz
   (:use clojure.test))

 (defn say [n] n)

 (are [n] (= n (say n))
   1
   2)
+(is (= "Fizz" (say 3)))

FizzBuzzでは3の倍数のときは"Fizz"と言うんでしたよね。

もちろんこのときテストは失敗します。
それを確認したら、今回は分かりきっているとしてFake Itはせずに実装に移りましょう。

 (ns fizzbuzz
   (:use clojure.test))

-(defn say [n] n)
+(defn say [n]
+  (cond (= 0 (mod n 3)) "Fizz"
+        :else n)

 (are [n] (= n (say n))
   1
   2)
 (is (= "Fizz" (say 3)))

これで実行すれば、テストは通ります。
"Fizz"を返す場合について、さらにテストを追加しておきます。

 (ns fizzbuzz
   (:use clojure.test))

 (defn say [n]
   (cond (= 0 (mod n 3)) "Fizz"
         :else n))

 (are [n] (= n (say n))
   1
   2)
+(are [n] (= "Fizz" (say n))
+  3
+  6)

ここで、さらに"are"マクロを用いてテストコードをまとめてしまいたいところです。

ですが、ちょっと待ってください。
2つのテストが確認している仕様は異なっていませんか?

  • 1つ目は3と5の倍数でないときはそのまま数字を返す
  • 2つ目は3の倍数のときに"Fizz"を返す

というものです。
コードだけ見ていれば、まとめてしまいたいところですが、仕様としては明らかに異なる仕様をテストしようとしています。
コメントが付いていないというのもありますが、どのような振る舞いについてテストしているのかコードからだとある程度しか把握できません。

ここは『言葉』の力を借りたいところです。

そこで"is"マクロを調べてみると、"is"マクロは実は最後の引数にどのようなテストかをメッセージとして受け取る引数があることを使ってみようかと考えます。
ですが、現在はテストコードをまとめて"are"マクロを使用しています。
残念ながら"are"マクロにはメッセージを受け取る機能はないみたいです。

そこで、テスト自体をまとめるための"testing"マクロというものを使ってみましょう。

 (ns fizzbuzz
   (:use clojure.test))

 (defn say [n]
   (cond (= 0 (mod n 3)) "Fizz"
         :else n))

+(testing "FizzBuzzは"
+  (testing "3の倍数でも5の倍数でもないとき、その数字を返す"
     (are [n] (= n (say n))
       1
+      2))
+  (testing "3の倍数のとき、Fizzを返す"
     (are [n] (= "Fizz" (say n))
       3
+      6)))

これを動作させても、なにもうれしいことはありません。
成功しているときになにも表示されないのは、"is"や"are"マクロのときと同じだからです。


しかし、さらにテストを追加する際に、同じように"testing"マクロを使うと何が違うのかが分かります。

 (ns fizzbuzz
   (:use clojure.test))

 (defn say [n]
   (cond (= 0 (mod n 3)) "Fizz"
         :else n))

 (testing "FizzBuzzでは"
   (testing "3の倍数でも5の倍数でもないとき、その数字を言う"
     (are [n] (= n (say n))
       1
       2))
   (testing "3の倍数のとき、Fizzと言う"
     (are [n] (= "Fizz" (say n))
       3
       6))
+  (testing "5の倍数のとき、Buzzと言う"
+    (is (= "Buzz" (say 5))))

これを実行するともちろん追加したテストが失敗します。

そのとき

FAIL in clojure.lang.PersistentList$EmptyList@1
FizzBuzzでは 5の倍数のとき、Buzzと言う
expected: (= "Buzz" (say 5))
  actual: (not (= "Buzz" 5))

のように表示されるはずです。

このとき、何という振る舞いを満たしていないかまで表示されるようになっています。

このように"testing"マクロでは、「振る舞い」、もしくは「仕様」を記述することができます。

このように、振る舞いに注目しているTDDのやり方をBDD(Behaviour Driven Development)と呼ぶこともあります。BDDのフレームワークとして有名なものとしてはRSpecというRuby用のフレームワークがあります。

さっさと3の倍数のときと同じように実装してしまいましょう。
(コードは略)

さて、しかしながらいまだにテストが成功したときに表示はありません。
これはどうにかしたいところです。

そこで、"deftest"というマクロを用いてみましょう。
"deftest"マクロはテストを関数にしてしまうことができます。

 (ns fizzbuzz
   (:use clojure.test))

 (defn say [n]
   (cond (= 0 (mod n 3)) "Fizz"
         (= 0 (mod n 5)) "Buzz"
         :else n))

+(deftest fizzbuzz-test
   (testing "FizzBuzzでは"
     (testing "3の倍数でも5の倍数でもないとき、その数字を言う"
       (are [n] (= n (say n))
         1
         2))
     (testing "3の倍数のとき、Fizzと言う"
       (are [n] (= "Fizz" (say n))
         3
         6))
     (testing "5の倍数のとき、Buzzと言う"
       (are [n] (= "Buzz" (say n))
         5
         10))))

これによって、テスト全体を関数(fizzbuzz-test)として実行できるようになりました。

でも、これでは元のままです。なにせ、関数を実行しても処理していることは今までと変わりはないのですから。

しかしながら、テストを定義した名前空間と同じ名前空間

(run-tests)

と言う関数を実行すると、

Testing fizzbuzz

Ran 1 tests containing 6 assertions.
0 failures, 0 errors.
{:type :summary, :test 1, :pass 6, :fail 0, :error 0}

と表示されるはずです。

run-tests関数は、名前空間内に定義されたテストを全て実行する関数です。
これを用いることで、テストの結果が成功したときにも見ることができるようになります。

最後の仕上げと例外テスト

残すところはあと3の倍数でかつ5の倍数のときに"FizzBuzz"というということだけです。

今回は、テストと実装を済ませたコードを載せておきます。

 (ns fizzbuzz
   (:use clojure.test))

 (defn say [n]
+  (cond (= 0 (mod n 15)) "FizzBuzz"
         (= 0 (mod n 3)) "Fizz"
         (= 0 (mod n 5)) "Buzz"
         :else n))

 (deftest fizzbuzz-test
   (testing "FizzBuzzでは"
     (testing "3の倍数でも5の倍数でもないとき、その数字を言う"
       (are [n] (= n (say n))
         1
         2))
     (testing "3の倍数のとき、Fizzと言う"
       (are [n] (= "Fizz" (say n))
         3
         6))
     (testing "5の倍数のとき、Buzzと言う"
       (are [n] (= "Buzz" (say n))
         5
         10))
+    (testing "3の倍数であり5の倍数でもあるとき、FizzBuzzと言う"
+      (are [n] (= "FizzBuzz" (say n))
+        15
+        30))))

実装してしまいました。

一安心です。
でもちょっと待ってください。

もしsayと言う関数に文字列が渡されたらどうしようか心配になりました。
そしてその時に期待する動きは「例外」を発生することだとします。

これをテストしたいとして、一体どうすればいいでしょうか。

このときthrown?やthrown-with-msg?と言う関数が役に立ちます。

;追加のテスト部分のみ抜粋
(testing "文字列を渡したら、ClassCastExceptionが発生する"
  (are [n] (thrown? java.lang.ClassCastException (say n))
    ""
    "1"
    "string"))

やり残したこと

ざっと眺めてみましたが、Clojureの既存の関数とclojure.testのマクロや関数を用いてテストがきちんと書けることが分かりました。

しかしながら、ここでは紹介していないけれど、使える機能というのもあります。
その代表的なものとしてはuse-fixturesというものがあります。

これをつかうことで、テストの前処理と後処理をする関数を指定してテスト実行時にその関数を実行させることができるようになります。

(defn fixture-func [test-func]
  (set-up) ;前処理
  (test-func) ;テスト実行
  (tear-down)) ;後処理

(use-fixtures :each fixture-func) ;テスト関数それぞれについてfixture-funcを実行
(use-fixtures :once fixture-func) ;テスト全体に対して一回のみfixture-funcを実行

のようになっています。


また今回はclojure.testを用いたテストについてでしたが、ClojureではJUnitも使うことができます。



Clojureでも楽しくプログラミングするために、しっかりテストを書いていきましょう。

・・・

TDDもね!

*1:ClojureFizzBuzz+テストコードは「ClojureでFizzBuzzを書いてみた with テストコード」という記事を書いていますが、その時にはテストの書き方についてはあまり触れなかったのでもう少しテストについて掘り下げて書いてみようと思いました。