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-testscabal buildcabal 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.TestDataList.FunctorSpecList.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と<*>で云々…