Haskellのhspecについて、まあ、すでにやっている人はやっているだろうし、今更感が否めない記事です。僕のスキルの低さではなかなかレベルが高くて辛かったのでメモになっています。なお、本記事のほぼ全部の部分については以下のエントリーを参考にしています。正直、この記事がなければ僕はまだhspecを実行できなかったと思う。
雑な紹介
プロジェクトの構造
プロジェクトの構造は次のような感じになります。(プロジェクトの名前はmy-project
)
my-project ┣ LICENSE ┣ Setup.hs ┣ cabal.config ┣ cabal.sandbox.config ┣ my-project.cabal ┣ src ┃ ┗ MyUtil.hs ┗ test ┗ Main.hs
src
ディレクトリーにはプロダクトコードを入れます。test
ディレクトリーにはテストコードを入れます。LICENSE
、Setup.hs
、my-project.cabal
ファイルはcabal init
をした時に生成されます。- 僕はsandboxを共有していたりするので、
cabal.config
(シンボリックリンク)とかcabal.sandbox.config
とかが入っていますが、GitHubなどで共有する場合などはいらないと思います。 - ビルドをした時には
dist
ディレクトリーが生成されますが、これもGitHubなどで共有する場合にはいらないと思います。
なお、上記の説明で「思います」と書いているところは、あまり自身がないのでつっこみ歓迎です。
hspecの書き方
結構簡単です。
import Test.Hspec.Core.Spec import Test.Hspec.Expectations main :: IO () main = hspec spec spec :: Spec spec = describe "List is instance of Functor" $ do it "satisfies Functor law : fmap id == id" $ fmap id [1,2,3] `shouldBe` [1,2,3] it "satisfies Functor law : fmap (g.h) f == (fmap g . fmap h) f" $ fmap ((+1).(*2)) [1,2,3] `shouldBe` fmap (+1) $ fmap (*2) [1,2,3] it "fail test" $ expectationFailure "by default"
describe
でテスト全体として何を示そうとしているのか記述しますit
でテスト内容を記述しますshouldBe
で値のテストを行います
cabalファイル
テスト用にcabal init
で生成されたcabalファイルにテスト用の設定を記述します。
test-suite unit-test type: exitcode-stdio-1.0 main-is: Main.hs ghc-options: -Wall hs-source-dirs: test build-depends: base >=4.7 && <4.8, my-project, hspec-core ==2.1.7, hspec ==2.1.7, hspec-expectations ==0.6.1.1, QuickCheck ==2.7.6 default-language: Haskell2010
- テスト用の設定は
test-suite
を先頭に書くっぽいです。 test-suite
の次にテストの名前を書くっぽいです。- hspecを
cabal test
で流す場合は、mainをただ実行するだけなので、my-project.cabal
のテスト設定の部分にmainがあるファイル名を指定しておきます。 - 自分のプロジェクトを参照するために
build-depends:
のエントリーに自分のプロジェクトmy-project
を記述しておきます。
cabalでテストの実行
cabalでhspecを流すときは次の順番でコマンドを流します
cabal configure --enable-tests
cabal build
cabal test
これを実行すると、次のようなログが標準出力に流れます
Preprocessing test suite 'unit-test' for my-project-0.1.0.0... Running 1 test suites... Test suite unit-test: RUNNING... Main List is instance of Functor satisfies Functor law : fmap id == id satisfies Functor law : fmap (g.h) f == (fmap g . fmap h) f fail test FAILED [1] Failures test/Main.hs:13: (best-effort) 1) Main, List is instance of Functor, fail test by default Source locations marked with "best-effort" are calculated heuristically and may be incorrect. Randomized with seed 575178167 Finished in 0.0035 seconds 3 examples, 1 failure Test suite unit-test: FAIL Test suite logged to: dist/test/my-project-0.1.0.0-unit-test.log 0 of 1 test suites (0 of 1 test cases) passed.
Automatic spec discovery
テストが一つのファイルで書ければいいのですが、実際にはテストは複数のファイルで書くことと思います。
例えば、テストを次のようなモジュールに分けたとします。
List.TestData
List.FunctorSpec
List.ApplicativeSpec
これを一つのMain.hs
から呼び出すにはMain.hs
を次のように書かないといけません。
import Test.Hspec.Core.Spec import qualified List.FunctorSpec LF import qualified List.ApplicativeSpec LA main :: IO main = hspec $ do describe "List is Functor" LF.spec describe "List is Applicative" LA.spec
これは結構面倒なので、hspecのAutomatic spec discoveryを使います。これはghcのプリプロセッサーによってモジュールを自動的に認識してくれる機能です。この機能を用いると、Main.hs
は次のような記述になります。
{-# OPTIONS_GHC -F -pgmF hspec-discover #-}
Automatic spec discoveryの制約
ドキュメントを読むといくつか制約があるようです。
- specファイルはテストドライバーと同じディレクトリーか、そのサブディレクトリーに置いておくこと
- specファイルは必ず
Spec.hs
で終わること。また、モジュール名もSpec
で終わること - 各モジュールは必ずトップレベルで
Spec
を返すspec
という関数を定義すること
テストを楽に書く
境界値テストを書くようなときに、毎回it "1 is odd" $ ...
、it "2 is even" $ ...
と書くのが面倒だったり、同じテストデータを使いまわしたい衝動に駆られることがあります(ないかもしれません)(また、僕が書いていたテストはリストがFunctor
則を満たすこととか、リストがMonoid
則を満たすこととかをテストしようとしていたので、同じリストを書くのが面倒だった)。
hspecのコードを見ているとdescribe "foo" $ do
とdo
構文が使われているので、リストとmapM_
を使って、上記の衝動を満たします。
上記の衝動の中で、僕が欲しい関数は次のような関数です
- 引数に法則の片方を示す関数を取る
- 引数に法則が満たすべき式を示す関数を取る
- テストデータを引数に取る
- 最終的に
describe
に渡す値を作りたいのでit
が返す型SpecWith(Arg a)
を返したい - できればテストデータをテストの名前に埋め込みたい
上記の要件を満たす関数の型を定義すると次のようになります。
itSatisfies :: (Show a, Show b, Eq b) => (a -> b) -> (a -> b) -> a -> SpecWith (Arg Expectation)
で、実装的には最初の関数にテストデータを適用したものと、次の関数にテストデータを適用したものとを比較するだけなので、実装を含めてこのようになります。
itSatisfies :: (Show a, Show b, Eq b) => (a -> b) -> (a -> b) -> a -> SpecWith (Arg Expectation) itSatisfies lf rf data = it ("satisfies on " ++ data) $ (lf data) `shouldBe` (rf data)
この関数をテストデータのリストの各要素に適用すればいいので、specはこのように書けます。
import Test.Hspec.Core.Spec import Control.Monad (mapM_) import List.TestData spec :: Spec spec = do describe "list satisfies Functor law" $ do describe "law1 : fmap id == id" $ do mapM_ (itSatisfies law1LF id) testLists describe "law2 : fmap (g.h) f = (fmap g . fmap h) f" $ do mapM_ (itSatisfies law2LF law2RF) testLists law1LF :: (Eq a) => [a] -> [a] law1LF = fmap id law2LF :: (Num a, Functor f) => f a -> f a law2LF = fmap ((+1).(^2)) law2RF :: (Num a) => [a] -> [a] law2RF xs = fmap (+1) $ fmap (^2) xs
なお、testLists
はList.TestData
で定義されており[[], [1], [1,2..9]]
を返します。
で、これをcabal test
で実行すると次のように表示されます。
List.Functor list satisfies Functor law law1 : fmap id == id satisfies on [] satisfies on [1] satisfies on [1,2,3,4,5,6,7,8,9] law2 : fmap (g.h) f == (fmap g . fmap h) f satisfies on [] satisfies on [1] satisfies on [1,2,3,4,5,6,7,8,9]
うむ、狙い通りできました。
…あれ、そういえば、shouldBe
は(.)
に対応するものだし、(a -> b)
はApplicative
だからpure
と<*>
で云々…