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
などを見ればわかる通りSpec
はMonadIO
ではないため、
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.
通りのことをしただけなんですが。
ただhspecの型はややこしくて、たったこれだけを理解するのに時間がかかりました。
beforeとbeforeAllの違い
before
とbeforeAll
の違いはシンプルで、
beforeAll
は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
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回だけ行われるというわけですね。
単一テストだとメモ化のコストがあるのでbefore
はbeforeAll
より無駄が少なく、
beforeAll
はメモ化するので生成に時間がかかるデータを必要とするテストを複数件実行したい場合に高速になる、という違いがあります。
後、テスト内部とかafter
で副作用でファイルを作ったり消したりする場合は、
before
を使った方が汚染されないかもしれませんね。
まとめ
runIO
は手っ取り早く物事を解決する手段に思えますが、実は罠的な動作があるので気をつけましょう。
hspecの型は割と複雑ですが、理解しようと読み込めば分かってきます。