mike-neckのブログ

Java or Groovy or Swift or Golang

hspecを使ってcabalでテストしてみた

Haskellのhspecについて、まあ、すでにやっている人はやっているだろうし、今更感が否めない記事です。僕のスキルの低さではなかなかレベルが高くて辛かったのでメモになっています。なお、本記事のほぼ全部の部分については以下のエントリーを参考にしています。正直、この記事がなければ僕はまだhspecを実行できなかったと思う。

github.com


雑な紹介

プロジェクトの構造

プロジェクトの構造は次のような感じになります。(プロジェクトの名前はmy-project)

my-project
┣ LICENSE
┣ Setup.hs
┣ cabal.config
┣ cabal.sandbox.config
┣ my-project.cabal
┣ src
┃ ┗ MyUtil.hs
┗ test
  ┗ Main.hs
  • srcディレクトリーにはプロダクトコードを入れます。
  • testディレクトリーにはテストコードを入れます。
  • LICENSESetup.hsmy-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を流すときは次の順番でコマンドを流します

  1. cabal configure --enable-tests
  2. cabal build
  3. 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" $ dodo構文が使われているので、リストと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

なお、testListsList.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<*>で云々…


Haskellで型が合わないときはぐぬぬとなりますが、コンパイル通ると幸せになれますね。