HUnitで動的なテストを自動化する
プログラムのバグは,型検査で静的に検出できるものだけではありません。型で検査している項目が不十分なことや,型を使って静的に検査を行うのが困難な問題もあります。こうした場合には動的なテストが必要です。
Haskellで動的なテストを自動化するためのライブラリとしては,HUnitとQuickCheckの二つがよく知られています。HUnitは,他の言語でおなじみのxUnitのHaskell版です。一方のQuickCheckは,テスト項目をランダムに自動生成してくれるという特徴があり,Haskellならではのテスト・ツールとしてよく話題に上ります。
QuickCheckはとても優れたテストツールです。ただ,QuickCheckを利用するメリットや,QuickCheckに現在存在する実装上の制限を理解するには,比較する対象が必要です。そこで,今回は皆さんが取っつきやすいと思われるHUnitを取り上げます。QuickCheckは次回以降で説明しようと思います。
HUnitを使用する前に,処理系にHUnitパッケージが含まれているかどうかをまず確認してください。
$ ghc-pkg list HUnit C:/ghc/ghc-6.8.2\package.conf: HUnit-1.2.0.0
$ ghc-pkg list HUnit /usr/local/lib/ghc-6.8.2/package.conf:
処理系にHUnitが付属していなかった場合には,Cabalを使用したHaskellのパッケージを集積したHackageDBというサイトから最新版のHUnitを入手し,第2回のコラムで説明したData.ByteStringのインストール方法を参考にインストールしてください。
では,HUnitを使ってみましょう。「静的な型検査で問題を発見できず,実行時に正しく振舞うことを保証しなければならないもの」の代表といえば,モナド則などモナドに関係する一連の規則です。この規則をテスト項目にしてみましょう。
module Main where import Test.HUnit import Control.Monad.State tests = test [ runState (return 12 >>= put) 11 ~?= runState (put 12) 11 , runState (get >>= return) 12 ~?= runState get 12 , runState (get >>= (\x -> put x >>= return)) 12 ~?= runState (get >>= put >>= return) 12]
HUnitはTest.HUnitモジュールをインポートすることで利用できます。このテストではStateモナドをテストするためにControl.Monad.Stateモジュールもインポートしていますが,このモジュールはHUnitの使用には直接関係ありません。
各テスト項目では,~?=演算子を使って結果が等しくなるかどうかを確かめています。test関数はリストとして並べたテストから,テスト項目を作成する関数です。これらのテスト項目をrunTestTTという関数で実行します。runTestTTのTTは,ターミナル(terminal)を使用したテキスト主体(test orientation)のテストという意味です(参考リンク)。
*Main> runTestTT tests Cases: 3 Tried: 3 Errors: 0 Failures: 0 Counts {cases = 3, tried = 3, errors = 0, failures = 0}
Casesがテスト項目の数,Triedが実際に実行されたテストの数,Errorsがエラーなど何らかの理由により途中で終了したテストの数,Failuresが失敗したテストの数です。上の結果ではErrorsやFiluresの数が0なので,三つのテストすべてが成功したとことになります。
もちろん,最初のテスト項目をわざと失敗するように書き換えれば,それを反映した結果になります。
tests = test [ runState (return 1 >>= put) 11 ~?= runState (put 12) 11 , runState (get >>= return) 12 ~?= runState get 12 , runState (get >>= (\x -> put x >>= return)) 12 ~?= runState (get >>= put >>= return) 12]
*Main> runTestTT tests ### Failure in: 0 expected: ((),12) but got: ((),1) Cases: 3 Tried: 3 Errors: 0 Failures: 1 Counts {cases = 3, tried = 3, errors = 0, failures = 1}
最初のテスト項目で,12という値がputに渡されるという期待に反して左側で1が渡されたために失敗した,ということがわかります。このように,~?=演算子では左側の式が右側の式で期待している答えと同じになるかどうかを検査します。
~?=とは別に,~=?という演算子もあります。この演算子では,検査する式を左側ではなく右側に置きます。
tests = test [ runState (return 1 >>= put) 11 ~=? runState (put 12) 11 , runState (get >>= return) 12 ~?= runState get 12 , runState (get >>= (\x -> put x >>= return)) 12 ~?= runState (get >>= put >>= return) 12]
*Main> runTestTT tests ### Failure in: 0 expected: ((),1) but got: ((),12) Cases: 3 Tried: 3 Errors: 0 Failures: 1 Counts {cases = 3, tried = 3, errors = 0, failures = 1}
また,ある式がBool型を返す場合には,~?演算子を使用することもできます。
*Main> null [] True *Main> null [1] False *Main> runTestTT $ test [null [] ~? "Is List null?"] Cases: 1 Tried: 1 Errors: 0 Failures: 0 Counts {cases = 1, tried = 1, errors = 0, failures = 0} *Main> runTestTT $ test [null [1] ~? "Is List null?"] ### Failure in: 0 Is List null? Cases: 1 Tried: 1 Errors: 0 Failures: 1 Counts {cases = 1, tried = 1, errors = 0, failures = 1}
このように,~?演算子を使えば式の答えがTrueになるかどうかを確認できます。ただし,真偽値に対するテストでは,エラー・メッセージの意味が不明確になりがちです。そこで~=?演算子や~?=演算子の場合とは異なり,テストの目的を記述した文字列を取り,それをエラー・メッセージとして利用するようにしています。
もちろん,~=?や~?=を使って,このような文字列を書かずに真偽値に対するテストを行うこともできます。
*Main> runTestTT $ test [True ~=? null []] Cases: 1 Tried: 1 Errors: 0 Failures: 0 Counts {cases = 1, tried = 1, errors = 0, failures = 0} *Main> runTestTT $ test [True ~=? null [1]] ### Failure in: 0 expected: True but got: False Cases: 1 Tried: 1 Errors: 0 Failures: 1 Counts {cases = 1, tried = 1, errors = 0, failures = 1}
テスト用の演算子について,それぞれの情報を見てみましょう。
Prelude Test.HUnit> :i ~? (~?) :: (AssertionPredicable t) => t -> String -> Test -- Defined in Test.HUnit.Base infix 1 ~? Prelude Test.HUnit> :i ~?= (~?=) :: (Eq a, Show a) => a -> a -> Test -- Defined in Test.HUnit.Base infix 1 ~?= Prelude Test.HUnit> :i ~=? (~=?) :: (Eq a, Show a) => a -> a -> Test -- Defined in Test.HUnit.Base infix 1 ~=?
テストを行う式の邪魔にならないよう,これらの演算子の結合の優先順位はとても低くなっています。Test型はテスト項目を表すものですね。では,~?演算子に文脈として付いているAssertionPredicableクラスとは何でしょうか?
Prelude Test.HUnit> :i AssertionPredicable class AssertionPredicable t where assertionPredicate :: t -> AssertionPredicate -- Defined in Test.HUnit.Base instance AssertionPredicable Bool -- Defined in Test.HUnit.Base instance (AssertionPredicable t) => AssertionPredicable (IO t) -- Defined in Test.HUnit.Base Prelude Test.HUnit> :i AssertionPredicate type AssertionPredicate = IO Bool -- Defined in Test.HUnit.Base
Bool型とIO Bool型を区別せずに扱うためのラッパーの役割を持つクラスであることがわかります。BoolはこのクラスのメソッドによってIO Boolに変換されるので,~?ではBoolもIO Boolも同じように扱えるのです。
*Main> runTestTT $ test [(null []::Bool) ~? "Is List null?"] Cases: 1 Tried: 1 Errors: 0 Failures: 0 Counts {cases = 1, tried = 1, errors = 0, failures = 0} *Main> runTestTT $ test [(return $ null []::IO Bool) ~? "Is List null?"] Cases: 1 Tried: 1 Errors: 0 Failures: 0 Counts {cases = 1, tried = 1, errors = 0, failures = 0} *Main> runTestTT $ test [(do {return $ null [1]}::IO Bool) ~? "Is List null?"] ### Failure in: 0 Is List null? Cases: 1 Tried: 1 Errors: 0 Failures: 1 Counts {cases = 1, tried = 1, errors = 0, failures = 1}
さて,ここまで見てきたテスト項目は,1個から3個と少ないものでした。このため,何番目のテストかさえ見ていれば,どのテストが失敗しているのかが一目で理解できました。しかし,現実のテストではそのような少ないテスト項目数で済むことはまずありません。
テストが多くなってくると,どのテストが失敗したかをすぐに把握するための名前(label)が必要になります。HUnitでは,このような目的のために~:演算子を用意しています。この演算子を使って,以下のようにテストに名前を付けられます。
tests = "Monad Laws" ~: test [ "return a >>= k == k a" ~: runState (return 1 >>= put) 11 ~?= runState (put 12) 11 , "m >>= return == m" ~: runState (get >>= return) 12 ~?= runState get 12 , "m >>= (\\x -> k x >>= h) == (m >>= k) >>= h" ~: runState (get >>= (\x -> put x >>= return)) 12 ~?= runState (get >>= put >>= return) 12]
*Main> runTestTT tests ### Failure in: Monad Laws:0:return a >>= k == k a expected: ((),12) but got: ((),1) Cases: 3 Tried: 3 Errors: 0 Failures: 1 Counts {cases = 3, tried = 3, errors = 0, failures = 1}
「return a >>= k == k a」という名前のテストが失敗したことが一目でわかりますね。
ただ,名前がリストの外側と内側の両方に付けられるのを見て,少し驚いたかもしれません。どのような定義になっているのでしょうか?
~:演算子やtest関数は,以下のように定義されています。
Prelude Test.HUnit> :i ~: (~:) :: (Testable t) => String -> t -> Test -- Defined in Test.HUnit.Base infixr 0 ~: Prelude Test.HUnit> :i test class Testable t where test :: t -> Test -- Defined in Test.HUnit.Base
これらに共通しているのは,Testableクラスのインスタンスを引数として取ることと,Test型を返すことです。まず,Testableクラスを見てみましょう。
Prelude Test.HUnit> :i Testable class Testable t where test :: t -> Test -- Defined in Test.HUnit.Base instance Testable Test -- Defined in Test.HUnit.Base instance (Assertable t) => Testable (IO t) -- Defined in Test.HUnit.Base instance (Testable t) => Testable [t] -- Defined in Test.HUnit.Base
Testableクラスのインスタンスに,Test型とリストが存在するのがわかります。したがって,Test型のリストである[Test]もやはりTestableクラスのインスタンスです。
次に,Test型の定義を見てみましょう。
Prelude Test.HUnit> :i Test data Test = TestCase Assertion | TestList [Test] | TestLabel String Test -- Defined in Test.HUnit.Base instance Show Test -- Defined in Test.HUnit.Base instance Testable Test -- Defined in Test.HUnit.Base Prelude Test.HUnit> :i Assertion type Assertion = IO () -- Defined in Test.HUnit.Lang
データ構成子TestListがTest型のリストを持つことで,Test型はリストではなく木構造を持つようになっています。上で見たようにtestはTestableクラスのメソッドです。なので,testの役割は「Test型のListに対してデータ構成子TestListを付ける」というように,Testableクラスのインスタンスになっている型をTest型に適宜変換していくことだと想像できます。
また~:の振る舞いは,データ構成子TestLabelを使ってTest型に名前を付けるものだということもわかります。したがって,:~は「Test木の節(node)であるTestCase」と「Test型のリストからtestメソッドによってTest木の分岐(branch)に変換したTestList」の両方に対して,同じように名前を付けられるのです。
Test木とラベルは,テストの構造化に役立ちます。積極的に利用して,わかりやすいテストを目指しましょう。