Haskellのモックライブラリmockcatで型クラスのモックが簡単に作成できるようになりました
はじめに
先日Haskellのモックライブラリmockcat
を作ったのですが、1stリリース時点では入れなかった機能を追加したので紹介させてください。
(リリースしたときの記事はこちら)
何ができるようになったか、というとタイトル通りなのですが
モナド型クラスのモックの作成
です(他にも追加したものはあるのだけど、メインはコレ)。
このような機能は、既存のライブラリである MonadMock や HMock が提供しており、逆に私が作ったmockcatはこれら既存のライブラリができないことができるので、組み合わせて使えばいいと思ってたんですね。
しかし以下の点から気が変わって作ることにしました。
- 私のmockcatも既存のライブラリも独自の記法があり、混在すると認知不可が増える。
- ユーザーは依存関係は増やしたくない(モックライブラリは一つで完結させたい)かもと想像した。
- 単純にTemplateHaskellを使ってみたかった。
モナド型クラスのモック
例
例えばこのようなモナド型のクラスがあるとします。
class Monad m => FileOperation m where
readFile :: FilePath -> m Text
writeFile :: FilePath -> Text -> m ()
そしてこの型クラスを利用する次のような関数があるとします。
program ::
FileOperation m =>
FilePath ->
FilePath ->
m ()
program inputPath outputPath = do
content <- readFile inputPath
writeFile outputPath content
makeMock
関数を次のように使うことで、FileOperation
のMockT
インスタンスを生成することができます。
makeMock [t|FileOperation|]
モックを使うテストはこのようになります。
spec :: Spec
spec = do
it "Read, and output files" do
result <- runMockT $ do
_readFile $ "input.txt" |> pack "content"
_writeFile $ "output.txt" |> pack "content" |> ()
program "input.txt" "output.txt"
result `shouldBe` ()
_readFile
や_writeFile
がいわゆるスタブ関数です。
readFile
のスタブ関数が_readFile
で、writeFile
のスタブ関数が_writeFile
というように、元の関数の頭に_
がついたものがスタブ関数になります。
(これはデフォルトの動作で、気に入らなければオプションで変更できます。)
このテストにおける_readFile
、_writeFile
はそれぞれ次のように使われています。
-
readFile
関数が"input.txt"
に適用されたらpack "content"
を返す -
writeFile
関数が"output.txt"
とpack "content"
に適用されたら()
を返す
runMockT
はテスト対象の関数の適用結果を返せるようになっているので、result
はprogram
関数の適用結果になります。
最終的にresult `shouldBe` ()
で適用結果の検証を行いテストは終了します。
適用結果まで検証しない(writeFile
が期待通り適用されたことが検証できれば十分)ということであれば次のようにexample
を使ってshouldBe
の部分は省略してしまってもよいかもしれません。
spec :: Spec
spec = do
it "Read, and output files" do
example $ runMockT $ do
_readFile $ "input.txt" |> pack "content"
_writeFile $ "output.txt" |> pack "content" |> ()
program "input.txt" "output.txt"
関数が適用された回数を検証する
例えば先ほどのテスト対象が次のように、特定の文字列を含む場合、writeFile
の適用を行わないという関数だったとします。
program ::
FileOperation m =>
FilePath ->
FilePath ->
m ()
program inputPath outputPath = do
content <- readFile inputPath
unless (pack "ngWord" `isInfixOf` content) $
writeFile outputPath content
この場合は、writeFile
の適用が一度も行われない(=適用回数が0回)ことを検証したいと思います。
この検証を実現するのがapplyTimesIs
関数で、次のように使います。
import Test.MockCat as M
it "読み込んだ内容にNGワードが含まれる場合、書き込みは行わない" do
example $ do
runMockT $ do
_readFile $ "input.txt" |> pack "a ngWord!"
_writeFile ("output.text" |> M.any |> ()) `applyTimesIs` 0 -- ★ここ
program "input.txt" "output.text"
あるいはこのように一度も適用されていないことは、neverApply
関数でも検証できます。
it "読み込んだ内容にNGワードが含まれる場合、書き込みは行わない" do
example $ do
runMockT $ do
_readFile $ "input.txt" |> pack "a ngWord!"
neverApply $ _writeFile $ "output.text" |> M.any |> ()
program "input.txt" "output.text"
モナド型クラスのモックと普通の関数のモックを組み合わせる
モナド型クラスのモックにより型クラスを利用した関数のテストが行えるようになりました。
しかし型クラスに定義された関数だけで常にやりたいことを実現できるでしょうか?
型クラスの関数の適用結果に、別の純粋な関数を適用させたいということはないでしょうか?
例えばさきほどの例で用いた関数が次のように純粋な関数と組み合わさっていた場合ですね。
program ::
FileOperation m =>
FilePath ->
FilePath ->
(Text -> Text) -> -- ★ここが増えた
m ()
program inputPath outputPath modifyText = do
content <- readFile inputPath
let modifiedContent = modifyText content -- ★ここが増えた
writeFile outputPath modifiedContent
こういった場合、テストとしてはreadFile
の適用結果に対し、modifyText
関数が適用されたことと、更にその結果にwriteFile
関数が適用されたことを確認したいはずです。
次の例のようにif
などを使って期待する引数に適用されたら期待値を返すような関数を作ればいいのでしょうが、毎回そのようなものを作るのは正直面倒です。適用された回数を検証するみたいなことをやりだすともっと面倒です。
example $ do
-- 自前のスタブ関数
let modifyContentStub t =
if t == pack "content" then pack "modifiedContent"
else error $ "contentが期待されたけど、" <> show t <> "に適用されたよ"
runMockT $ do
_readFile $ "input.txt" |> pack "contentx"
_writeFile $ "output.text" |> pack "modifiedContent" |> ()
program "input.txt" "output.text" modifyContentStub
このようなときはcreateStubFn
関数でスタブ関数を作れます。
example $ do
modifyContentStub <- createStubFn $ pack "content" |> pack "modifiedContent"
runMockT $ do
_readFile $ "input.txt" |> pack "content"
_writeFile $ "output.text" |> pack "modifiedContent" |> ()
program "input.txt" "output.text" modifyContentStub
適用回数はshouldApplyTimes
関数で検証することができます。
it "適用回数の検証が行える" do
m <- createMock $ "arg1" |> "arg2" |> True
stubFn m "arg1" "arg2" `shouldBe` True
stubFn m "arg1" "arg2" `shouldBe` True
m `shouldApplyTimes` (2 :: Int) $ "arg1" |> "arg2"
既存のモナドのモック
最後に既存のモナドをモックにしてみます。
さきほどの例の関数を、MonadReader
を使うように変えてみます。
NGワードのチェックを行うかどうかのオプションを用意して、MonadReader
で読み込んで使う、という変更です。
data Option = Option { checkNgWord :: Bool }
program ::
MonadReader Option m =>
FileOperation m =>
FilePath ->
FilePath ->
m ()
program inputPath outputPath = do
(Option checkNgWord) <- ask
content <- readFile inputPath
unless (checkNgWord && (pack "ngWord" `isInfixOf` content) ) do
writeFile outputPath content
次にmakeMock
関数でMonadReader Option
のモックを生成します。
makeMock [t|MonadReader Option|]
テストはこんな感じになるでしょう。
it "NGワードのチェックを行わない設定の場合、NGワードが含まれても、書き込みを行う" do
example $ do
runMockT $ do
_ask $ Option False
_readFile $ "input.txt" |> pack "a ngWord!"
_writeFile $ "output.text" |> pack "a ngWord!" |> ()
program "input.txt" "output.text"
it "NGワードのチェックを行う設定で、NGワードが含まれる場合、書き込みを行わない" do
example $ do
runMockT $ do
_ask $ Option True
_readFile $ "input.txt" |> pack "a ngWord!"
neverApply $ _writeFile $ "output.text" |> M.any |> ()
program "input.txt" "output.text"
it "NGワードのチェックを行う設定で、NGワードが含まれない場合、書き込みを行う" do
example $ do
runMockT $ do
_ask $ Option True
_readFile $ "input.txt" |> pack "content"
_writeFile $ "output.text" |> pack "content" |> ()
program "input.txt" "output.text"
既存のモナドもモック化することができましたね。
おわりに
自分が調べた限り、モナド型クラスをモックできるライブラリはありましたが、通常の関数をモックにできるライブラリは見つからなかったので、両方の機能を備えたことで他にはないライブラリになったのではないかと思います。
新しい機能は、思いついたら追加していく所存です。
テストで使ってくれたら嬉しいです。
フィードバックも歓迎します!
ではまた。
Discussion