• 作成:

hspecでIOアクションの結果の値を実行時に扱う方法

Hspec: A Testing Framework for Haskell でIOアクションの値を使いたい時に気をつけること。

単純にIOに包まれた値をテストしたい場合はshouldReturnが使えます

shouldReturn を使えばIOに包まれた値に対して比較が出来ます。

例。

module IoSpec (spec) where

import           Test.Hspec

-- | 例は雑。
foo :: IO Int
foo = pure 1

spec :: Spec
spec = it "foo" $ foo `shouldReturn` 1

これで済む場合は全てこれにしましょう。

単純でない場合にrunIOが使えるが、ツリー構築時に実行してしまう

shouldBeの右辺にもIOの結果値を使いたい場合とか、 hspec-golden の値としてIOアクションの評価した値を使いたい場合とかでは、 shouldReturnではどうにもならないと思います。

MonadIO · Issue #110 · hspec/hspec などを見ればわかる通りSpecMonadIOではないため、 liftIOは使えません。

そこで、 runIO が使えます。

module IoSpec (spec) where

import           Test.Hspec

-- | 例は雑。
foo :: IO Int
foo = pure 1

-- | 例は雑。
bar :: IO Int
bar = pure 1

spec :: Spec
spec = do
  b <- runIO bar
  it "foo" $ foo `shouldReturn` b

ちなみにitの内部ではIOアクションを実行する関数は書けないです。 Yesodでのhspecで基底モナドが違ったりしたら別ですが。

良かった、これで解決ですね。

とはならない。

テストツリー構築時にIOアクションが実行されてしまう

module IoSpec (spec) where

import           Test.Hspec

-- | 例は雑。
foo :: IO Int
foo = pure 1

-- | 例は雑。
bar :: IO Int
bar = do
  putStrLn "bar-test-start"
  pure 1

spec :: Spec
spec = do
  b <- runIO bar
  it "foo" $ foo `shouldReturn` b
  it "hoge" $ foo `shouldReturn` 1

このようにテストケースhogeを増やして、 hogeのみを実行させたいとします。

stack test --test-arguments='--match=hoge'

のようにすると、テストケースの実行はhogeのみと指定されますが、 putStrLnの残すログを見るとbarも実行されていることがわかります。

構築時に実行されるからですね。

今回はさほど問題はありませんし、構築時に予想外に実行された場合にシステムが壊れるとかはテストのやり方が間違っているのですが、これがbarの実行に30秒かかるとかの場合、他のテストケースだけを確認したいのに一々待たされて苛立たされることになります。

before, beforeAllを使う

このような問題はbeforeを使うことで解決することが出来ます。

module IoSpec (spec) where

import           Test.Hspec

-- | 例は雑。
foo :: IO Int
foo = pure 1

-- | 例は雑。
bar :: IO Int
bar = do
  putStrLn "bar-test-start"
  pure 1

spec :: Spec
spec = do
  before bar $ it "foo" $ \b -> foo `shouldReturn` b
  it "hoge" $ foo `shouldReturn` 1

これではちょっとわかりにくいので分割すると、

module IoSpec (spec) where

import           Test.Hspec

-- | 例は雑。
foo :: IO Int
foo = pure 1

-- | 例は雑。
bar :: IO Int
bar = do
  putStrLn "bar-test-start"
  pure 1

-- | 引数を一つ受取り比較するテストケースを定義します。
itWithArg1 :: (Show a, Eq a) => String -> IO a -> SpecWith a
itWithArg1 label actionResult = it label $ \expect -> actionResult `shouldReturn` expect

spec :: Spec
spec = do
  before bar $ itWithArg1 "foo" foo
  it "hoge" $ foo `shouldReturn` 1

のように引数を受け取るテストケースを追加することで型が合ってアクションの結果を渡せます。複数引数渡したい場合はタプルを渡せば良さそう。

まあ、 runIOの説明に書いてある、

Run an IO action while constructing the spec tree.

SpecM is a monad to construct a spec tree, without executing any spec items. runIO allows you to run IO actions during this construction phase. The IO action is always run when the spec tree is constructed (e.g. even when --dry-run is specified). If you do not need the result of the IO action to construct the spec tree, beforeAll may be more suitable for your use case.

Test.Hspec.Core.Spec

通りのことをしただけなんですが。

ただhspecの型はややこしくて、たったこれだけを理解するのに時間がかかりました。

beforeとbeforeAllの違い

beforebeforeAllの違いはシンプルで、 beforeAllIOアクションの結果をメモ化するので、複数テストケースの時でも一回しかアクションを実行しないというだけです。

つまり、

module IoSpec (spec) where

import           Test.Hspec

-- | 例は雑。
foo :: IO Int
foo = pure 1

-- | 例は雑。
bar :: IO Int
bar = do
  putStrLn "bar-test-start"
  pure 1

baz :: IO Int
baz = pure 1

-- | 引数を一つ受取り比較するテストケースを定義します。
itWithArg1 :: (Show a, Eq a) => String -> IO a -> SpecWith a
itWithArg1 label actionResult = it label $ \expect -> actionResult `shouldReturn` expect

spec :: Spec
spec = do
  before bar $ do
    itWithArg1 "foo" foo
    itWithArg1 "baz" baz
  it "hoge" $ foo `shouldReturn` 1

こういう風にbeforeの配下に複数置いた場合、 beforeだと2回putStrLnが行われますが、 beforeAllだと1回だけ行われるというわけですね。

単一テストだとメモ化のコストがあるのでbeforebeforeAllより無駄が少なく、 beforeAllはメモ化するので生成に時間がかかるデータを必要とするテストを複数件実行したい場合に高速になる、という違いがあります。

後、テスト内部とかafterで副作用でファイルを作ったり消したりする場合は、 beforeを使った方が汚染されないかもしれませんね。

まとめ

runIOは手っ取り早く物事を解決する手段に思えますが、実は罠的な動作があるので気をつけましょう。

hspecの型は割と複雑ですが、理解しようと読み込めば分かってきます。