テストには常にある種の不安が残ります。このテストは果たしてすべての場合に妥当だと言えるのか? 何か見落としてはいないか? その見落としは致命的なものではないか?
Haskellの静的な型検査をすり抜けてくるバグに対処するには,実際にプログラムを動作させ,HUnitなどで動的なテストを行います。では,動的なテストをすり抜けるバグにはどう対処すればいいでしょうか?
一番単純な対策は,テスト項目数を増やすことです。たいていの場合,テスト項目は「その型の取りうる値を想像し,その例に対してきちんと動作するかどうかを確かめる」という形で記述します。単純に考えるなら,テスト項目が増えれば増えるほどテストの正確さは増していくはずです。
しかし,個々の値に対してテストを記述していくのは手間のかかる作業です。テストを行うべき値の集合に対してテストを行うツールが欲しくなりますね。それが「データ駆動型のテスト・ツール(data-driven testing tool)」です。今回は,その一つであるQuickCheckを取り上げます。
QuickCheckの基本的な使い方
QuickCheckを使用する前に,処理系にQuickCheckパッケージが含まれているかどうかを確認してください。
$ ghc-pkg list QuickCheck C:/ghc/ghc-6.8.2\package.conf: QuickCheck-1.1.0.0, QuickCheck-2.0
$ ghc-pkg list QuickCheck /usr/local/lib/ghc-6.8.2/package.conf:
処理系にQuickCheckが付属していなかった場合には,前回のHUnitと同様に,HackageDBから最新版のQuickCheckを入手し,第2回のコラムで説明したData.ByteStringのインストール方法を参考にインストールしてください。
では,QuickCheckを使ってみましょう。Haskellプログラムの例としてよく引き合いに出されるクイックソート関数が正しく動作するかどうかを確かめてみます。
module QuickSort where import Test.QuickCheck prop_Quicksort xs = isSorted $ quicksort xs where types = xs::[Int] isSorted xs = and $ zipWith (<=) xs (drop 1 xs) -- quicksort関数の定義 quicksort [] = [] quicksort (x:xs) = quicksort [y | y <- xs, y<x ] ++ [x] ++ quicksort [y | y <- xs, y>=x]
prop_Quicksortは,クイックソートの基本的な定義であるquicksort関数をテストする関数です。prop_Quicksort関数への入力は,xsという変数として記述しています。出力は,xsにquicksort関数を適用した結果をisSorted関数でテストする,という形になっています。
次に,isSorted関数で何をテストしているのかを見ましょう。
zipWith関数はzip関数を汎用化したものです。第1引数として「二つのリストの値から一つのリストを生成する際に適用すべき関数」を取ります。
Prelude> :t zipWith zipWith :: (a -> b -> c) -> [a] -> [b] -> [c] Prelude> zipWith (+) [0..4] [1..5] [1,3,5,7,9] Prelude> zipWith (,) [0..4] [1..5] [(0,1),(1,2),(2,3),(3,4),(4,5)]
andは,リストの全要素がTrueである時のみTrueを返す関数です。
Prelude> and [True,True,True,True] True Prelude> and [True,True,False,True] False
したがって,isSorted関数は「リストとその次の要素を持つリストを比較し,リストの要素が常にその次の要素以下の大きさを持つことを検査する」という定義になっています。この仮定が守られていれば,quicksort関数は間違いなくリストを小さいものから順番に整列しているといえるでしょう。
このように定義したテストを,Test.QuickCheckモジュールのquickCheck関数を使ってテストします。
Prelude Test.QuickCheck> :t quickCheck quickCheck :: (Testable a) => a -> IO ()
quickCheck関数に渡せるのは,Testableクラスのインスタンスになっている型だけです。ただし,前回,紹介したHUnitのTestableクラスとは全く別のものです。
Prelude Test.QuickCheck> :i Testable class Testable a where property :: a -> Property -- Defined in Test.QuickCheck instance Testable () -- Defined in Test.QuickCheck instance Testable Bool -- Defined in Test.QuickCheck instance Testable Result -- Defined in Test.QuickCheck instance Testable Property -- Defined in Test.QuickCheck instance (Arbitrary a, Show a, Testable b) => Testable (a -> b) -- Defined in Test.QuickCheck
TestableクラスのインスタンスとしてはBool,Propertyなどが用意されています。テストの対象は関数なので,実際に重要なのは最後の部分(下から2行目)です。「Arbitraryクラスのインスタンスを取り,Testableクラスのインスタンスを返す関数」がTestableクラスのインスタンスになっていることがわかります。この関数自体もTestableクラスのインスタンスであるため,関数を返す関数もTestableクラスのインスタンスであるという再帰的な定義になっています。
この再帰的な定義をたどっていくと,「c -> (a -> b),d -> (c -> (a -> b)) ...」のように関数を返す関数をインスタンス化する過程を何度でも繰り返すことができます。「c -> (a -> b)」は「c -> a -> b」と同じであり,「d -> (c -> (a -> b))」は「d -> c -> a -> b」と同じです。なので,リストなどの再帰的なデータ構造が任意個の値を含むのと同様に,Testableクラスも再帰的なインスタンス宣言によって任意個の引数を持つ関数をインスタンスにできるのです。したがって,テストの対象とする関数の引数はいくつでも構いません。ただし,定義からわかるように,引数はArbitraryクラスのインスタンスであると同時にShowクラスのインスタンスである必要があります。
prop_Quicksort関数の型は,Intのリストを取ってBoolを返すようになっています。
*QuickSort> :i prop_Quicksort prop_Quicksort :: [Int] -> Bool -- Defined at QuickSort.hs:4:0-13
IntとリストがShowクラスのインスタンスであることは,(第2回)で説明しました。あとは,IntとリストがArbitraryクラスのインスタンスであるなら問題なくテストできるはずです。
Prelude Test.QuickCheck> :i Arbitrary class Arbitrary a where arbitrary :: Gen a coarbitrary :: a -> Gen b -> Gen b -- Defined in Test.QuickCheck instance (Arbitrary a) => Arbitrary (Maybe a) -- Defined in Test.QuickCheck instance (Arbitrary a, Arbitrary b) => Arbitrary (Either a b) -- Defined in Test.QuickCheck instance (Arbitrary a) => Arbitrary [a] -- Defined in Test.QuickCheck instance (Arbitrary a, Arbitrary b) => Arbitrary (a -> b) -- Defined in Test.QuickCheck instance Arbitrary () -- Defined in Test.QuickCheck instance Arbitrary Bool -- Defined in Test.QuickCheck instance Arbitrary Int -- Defined in Test.QuickCheck instance Arbitrary Integer -- Defined in Test.QuickCheck instance Arbitrary Float -- Defined in Test.QuickCheck instance Arbitrary Double -- Defined in Test.QuickCheck instance (Arbitrary a, Arbitrary b) => Arbitrary (a, b) -- Defined in Test.QuickCheck instance (Arbitrary a, Arbitrary b, Arbitrary c) => Arbitrary (a, b, c) -- Defined in Test.QuickCheck instance (Arbitrary a, Arbitrary b, Arbitrary c, Arbitrary d) => Arbitrary (a, b, c, d) -- Defined in Test.QuickCheck
QuickCheckの側でArbitraryクラスのインスタンスが用意されていることがわかりますね。したがってテストは問題なく実行できます。
*QuickSort Test.QuickCheck> quickCheck prop_Quicksort OK, passed 100 tests.
QuickCheckでは,入力に「Arbitraryクラスのarbitraryメソッドなどを使って自動生成した値」を使用します。なので,テストの側で想定される入力を渡す必要はありません。ただし,いくつかの点に気をつける必要があります。
一つは,入力に使う型は多相型であってはならないということです。必ず使用する型を決める必要があります。prop_Quicksortでは,where節のtypesでxsに対する型を宣言していました。この部分がないと自動生成すべき値の型がわからないため,処理系は「型があいまいだとエラーを出す」か,「適当な型を見つくろってその型の値を生成する」しかありません。型エラーで済むのならまだよいですが,処理系が適当な型を生成した場合,その型が「関数をテストするという目的」に適さない可能性があります。
例として「\x y -> x < y」というコードをテストすることを考えましょう。xとyの間には何の制約も存在しないため,このテストは必ず何らかの値で失敗します。QuickCheckは,失敗したテスト項目について,入力に使ったそれぞれの値を反例として出力します。ただ,出力のためには,値の表示方法がわからなければなりません。Testableクラスで関数の引数がShowクラスのインスタンスでなければならないのはこのためです。
実際に試してみましょう。
*QuickSort Test.QuickCheck> quickCheck $ \x y -> x < y Falsifiable, after 0 tests: () ()
失敗していることはわかりますが,ここで出力された反例は少々期待外れではないでしょうか? ()と()は等しいため,テストに失敗するのはわかります。しかし,()型の値は()と⊥以外には存在しません(参考リンク)。
Prelude> :i () data () = () -- Defined in GHC.Base instance Bounded () -- Defined in GHC.Enum instance Enum () -- Defined in GHC.Enum instance Eq () -- Defined in GHC.Base instance Ord () -- Defined in GHC.Base instance Read () -- Defined in GHC.Read instance Show () -- Defined in GHC.Show
期待していたのは,もう少し豊かな値の大小関係に基づいた反例です。これを実現するには,型注釈を使って自動生成する値をIntなどの具体的な型にする必要があります。
*QuickSort Test.QuickCheck> quickCheck $ \x y -> (x::Int) < y Falsifiable, after 2 tests: 3 3 *QuickSort Test.QuickCheck> quickCheck $ \x y -> (x::Int) < y Falsifiable, after 0 tests: -1 -3
同様の問題は,prop_Quicksortのような関数でも生じる可能性があります。()のリストを整列してみても,クイックソートが適切に動作しているかどうかを確かめることはできません。そこでwhere節のtypesで型を宣言しているのです。型を付けるべき変数が二つ以上ある場合は,「type1 = x1::t1, type2 = x2::t2 ...」とするのではなく,「types = (x1::t1, x2::t2, ...)」のように変数を組にして型を宣言します。