yunomuのブログ

趣味のこと

少しはテストを楽しくやる(QuickCheck)

テストって別にやりたくないわけじゃないっていうかやりたいんですけど、なんかつい後回しになってしまうというか。
でも例えば外部システムとの連携部分だったりすると私は割と真面目にテストを書くんですが、普段はなんかなんとなく面倒臭い。面倒くさいと面倒くさくないの境界線は何なんだ。

外部システム連携みたいな入出力系のテストがそれほど苦じゃないのは、どっちにしろ動作確認で動かすからなんですよね。
一方でそれ以外の部分、いわゆる計算というか、モジュール間のデータのやり取りだったりデータ変換だったりとか、そういう部分のテストがなんで面倒くさいかといったら、それは足し算をテストする時の事を考えるとだいたい想像つくと思いますけど、

@Test
void testPlus() {
    assertEqual(plus(1, 1), 2);
    assertEqual(plus(1, -1), 0);
    assertEqual(plus(2, 3), 5);
    assertEqual(plus(-5, 4), -1);
    ...

そこで浮かんでくる感情というのは「わかりきってるじゃんそんなこと」だったり、「a+b=cのa,b,cをどのくらい用意すればいいのかわからない」だったり、「いや並べておけばいいんだけどそんな単純作業面倒くさい」だったりで、一言で言うと面白くないわけです。面白くない。

これなんとか面白くならないものか。
……という観点で出会ったわけでは全然ないんですけど、ちょっとテストを書く用事ができて、QuickCheckを使ってみたらこれが割と面白かったのです。

QuickCheckというのは話には聞いたことがあったんですけど、要は関数の性質を検査するものらしいと。

性質とはなんぞや。
例えば、リストをソートする関数があった場合、実装がバブルソートだろうがクイックソートだろうが、それ以外の何か高速なアルゴリズムで実装されていようが、同じ入力に対する出力結果は同じです。こういう時に性質が同じと言う。
例えば、リストの順序を逆にする関数があった場合、任意のリストに対してその関数を2回適用すると元に戻るという性質がある。
例えば、ある数値を文字列に変換してそれをさらに数値に変換すると元の数値に戻ってほしい。
そういうものですよね。

そういう時にQuickCheckではこう書くらしい。といいつつこれコンパイルすらもしてないから動くのかはわからないけども。

import Test.Hspec
import Test.Hspec.QuickCheck (prop)
import Test.QuickCheck

spec :: Spec
spec = do
    describe "sort test" $ do
        prop "int" (test_sort :: [Int] -> Bool)
        prop "string" (test_sort :: [String] -> Bool)
    describe "reverse test" $ do
        prop "int" (test_reverse :: [Int] -> Bool)
        prop "char" (test_reverse :: [Char] -> Bool)
    describe "transform test" $ do
        prop "int to string" test_int_str

-- バブルソートとクイックソートは同じ結果になる
test_sort :: Ord a => [a] -> Bool
test_sort xs = bubble_sort xs == quick_sort xs

-- リストの逆順の逆順は元と同じになる
test_reverse :: [a] -> Bool
test_reverse xs = xs == reverse (reverse xs)

-- 数値を文字列に変換して、それを数値に変換すると元に戻る
test_int_str :: Int -> Bool
test_int_str i = i == parseInt (toString i)

ここのtest_sort, test_reverse, test_int_strで、さっき挙げた例と同じ事をやっています。
この3つの関数は全部何らかの入力を受けて、関数の性質が正しければTrueを返すようにしています。
じゃあこの「何らかの入力」というのは何なのかというと、任意の[Int]だったり[String]だったりIntだったり、それはprop関数が値の型から自動的にランダムな値を選んで適当に突っ込んでくれるらしい。

どういう仕組なのかというと、これ。
Test.QuickCheck.Arbitrary
Test.QuickCheckをimportするとIntとか[Int]とかCharとかそのあたりがあらかたArbitraryのインスタンスになって、arbitrary関数を呼び出すと、その型のランダムな値が取り出せるようになる。このランダムな値を使ってpropが適当に入力を生成してテストしてくれるというわけらしい。
生成する型インスタンスの情報が必要なので、sortとかreverseでは型注釈を書かないといけなかったわけです。
運悪く間違えてテストが通ってしまうとかありそうな気がしないでもないけど、まあ自動テストして何度も回していればいつか気づくでしょうとか、そういう感じでしょうか。そこら辺はよくわかりませんが。

で、自分で作ったデータ型に関してもArbitraryのインスタンスにしてっていうか、あの辺のモジュールにあるGenを生成するchooseとかelementsだとかいう関数を使ってarbitraryを実装してあげれば、任意の値を使った性質のテストが書けるという。

例は私が書いたものじゃないんですけど。山本先生のIPアドレス表現関係のライブラリです。
iproute/test/RouteTableSpec.hs at readaddr · yunomu/iproute · GitHub
自作のIPv4とかIPv6のデータをArbitraryのインスタンスにして、それぞれのCIDRもvalidなものを自動生成するようなコードが上の方に固まっています。

これに対して私がPull Requestを送った時に書いた異常系のテストがこっち。
iproute/test/IPReadSpec.hs at readaddr · yunomu/iproute · GitHub
InvalidIPv4とかそういうデータを定義して、異常な入力値を作っている。ここでは例えばIPv4のCIDRのマスクビット数が0-32以外の値である場合を作っています。文字とか突っ込んでもよかったなぁ。
これらを使ったテストを下の方に書いてます。今回はread関数をテストしたかったんですけど、SafeのreadMayを使ってテストするのもアリかなと思ってこんな感じにしてみましたっていうか、readMayでも問題なく使えるようにするのが目的だったんですが。

こういうのを使って正常な値を自動生成するとか異常な値を自動生成するとかいう作業は割と楽しかったりする。というか楽しかったのです。
どうやってテストできるだろうとか正常値をどう表現しようとか考えたりするのは大変だけど楽しいですよね。コピペは楽しくない。

参考というか、実際は参照してなかったりするけども。
Haskellの単体テスト最前線 - あどけない話
あと全然関係ないけどPull Requestする時に参考になった。
お気軽 pull request - Quickhack Diary