🐾

Haskellのモックライブラリmockcatで型クラスのモックが簡単に作成できるようになりました

2024/08/23に公開

はじめに

先日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関数を次のように使うことで、FileOperationMockTインスタンスを生成することができます。

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はテスト対象の関数の適用結果を返せるようになっているので、resultprogram関数の適用結果になります。
最終的に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