🐈

[Haskell] mockcatが部分的なモックとIOアクションのスタブ関数を作れるようになりました!

2024/09/26に公開

私はここのところコツコツとモックライブラリmockcatの開発を続けてきたのですが、先日のリリースで、部分的なモックとIOアクションのスタブを作れるようになったので紹介します。

これまでの記事
https://zenn.dev/funnycat/articles/9beb2108ab8ddd
https://zenn.dev/funnycat/articles/8f77cf83e4f26a

部分的なモック

バージョン0.4.0から型クラスの一部の関数のみをスタブ関数にすることができるようになりました。

例えば型クラスApiClientと、その型クラスを利用するfetchDataという関数があるとします。

class Monad m => ApiClient m where
  makeRequest :: String -> m ByteString
  parseResponse :: ByteString -> m (Maybe Int)

fetchData :: ApiClient m => String -> m (Maybe Int)
fetchData url = do
  response <- makeRequest url
  parseResponse response

この例では一部本物の関数を使いたいので、IOインスタンスを用意します。

instance ApiClient IO where
  makeRequest url = do
    request <- parseRequest url
    response <- httpLBS request
    pure $ getResponseBody response

  parseResponse response = 
    case decode response of
      Just (ApiResponse obj) -> pure (Just obj)
      Nothing -> pure Nothing

newtype ApiResponse = ApiResponse { value :: Int }
  deriving (Generic, Show)

instance FromJSON ApiResponse

makeRequest関数は、外部へHTTP通信を行うため、テストではモックに差し替えたいとします。
一方parseResponseは外部への依存がないため、実関数でテストしたいです。
つまり部分的にモックにしたいわけです。
こういうときは、makePartialMock関数を使います。

makePartialMock [t|ApiClient|]

この関数を使うと、スタブ関数を用意しなかった関数は本物の関数になります。
使用例を見てみましょう。

spec :: Spec
spec = do
  it "Fetch data with mocked response" do
    result <- runMockT do
      _makeRequest $ "https://example.com" |> BS.pack "{\"value\": 42}"
      fetchData "https://example.com"
    result `shouldBe` Just 42

  it "Handle invalid response" do
    result <- runMockT do
      _makeRequest $ "https://example.com" |> BS.pack "invalid response"
      fetchData "https://example.com"
    result `shouldBe` Nothing

2ケースのテストがありますが、どちらもmakeRequestのスタブ関数だけが用意されています。
そのためparseResponseは本物の関数が使われます。
テストを実行する際のコンテキストはIOのため、IOインスタンスのparseResponseが使われます。
このように、テストしづらい部分だけをスタブ関数にしつつ、目的の関数のテストが行えるわけです。

テストの全体はこちらです。

IOアクションのスタブ

バージョン0.5.0から、IO a型を返すスタブ関数に限り、適用する度に異なる値を返すようなスタブ関数が作れるようになりました!
ちなみに定数的に同じ値を返すことは以前のバージョンからできます。

IO a型を返す関数とは次のようなシグニチャの関数です。

returnIO :: IO a
returnIO = ...

実例を示します。
次のような型クラスTeletypeと、関数echoがあるとします。

class Monad m => Teletype m where
  readTTY :: m String
  writeTTY :: String -> m ()

echo :: Teletype m => m ()
echo = do
  i <- readTTY
  case i of
    "" -> pure ()
    _  -> writeTTY i >> echo

関数echoは、readTTYの結果次第で処理が分岐しています。
空文字が返されたら()を返し、そうでなければwriteTTYを適用した後再帰します。
そのため最低限空文字が返された場合とそうでない場合の2パターンのテストを書きたいでしょう。
空文字を返す場合のテストは問題なく書けそうなのがわかると思いますが、空文字以外を返す場合のテストはそう簡単ではありません。
空文字以外が返された場合は再帰しますが、どこかで空文字以外を返さなければ無限ループしてしまうからです。
つまり適用する度に異なる値を返せる必要があります。
しかも引数がない関数でそれを実現できなければなりません。

それができることを示しましょう。
まず、次のようにオプションを付けてモックを生成します。
implicitMonadicReturn = Falseは明示的にモナディックな値を返せるようにするオプションです。

makeMockWithOptions [t|Teletype|] options { implicitMonadicReturn = False }

テストは次のようになります。
2つ目のケースの_readTTYで、1回目は"a"を返し、2回目以降は空文字を返すスタブ関数が用意されています。

spec :: Spec
spec = doit "The process ends when an empty string is returned." do
    result <- runMockT do
      _readTTY $ pure @IO ""
      echo
    result `shouldBe` ()

  it "If a non-empty character is returned, it is recursed." do
    result <- runMockT do
      _readTTY $ do
        onCase $ pure @IO "a"
        onCase $ pure @IO ""

      _writeTTY $ "a" |> pure @IO ()
      echo
    result `shouldBe` ()

テストの全体はこちらです。

[補足]
デフォルトでは_readTTY ""のように通常の値を返した場合、mockcatが自動的にpureで包んで返すようになっていますが、今回の例の場合はIOであることを明示したいため、モックを作る際implicitMonadicReturn = Falseとしています。

おまけ(Polysemy版のTeletype)

Polysemy(拡張可能作用)版のTeletypeのテストもmockcatで実現できます。

まずデータ型と関数を定義します。

data Teletype m a where
  ReadTTY  :: Teletype m String
  WriteTTY :: String -> Teletype m ()

makeSem ''Teletype

echo :: Member Teletype r => Sem r ()
echo = do
  i <- readTTY
  case i of
    "" -> pure ()
    _  -> writeTTY i >> echo

これに対してテストは次のようになります。

spec :: Spec
spec = do
  it "Teletype" do
    readTTYStubFn <- createStubFn do 
      onCase $ pure @IO "output"
      onCase $ pure @IO ""
    writeTTYMock <- createMock $ "output" |> pure @IO ()

    let runEcho = interpret $ \case
          ReadTTY -> embed readTTYStubFn
          WriteTTY text -> embed $ stubFn writeTTYMock text

    result <- (runM . runEcho) echo

    result `shouldBe` ()
    writeTTYMock `shouldApplyTo` "output"

interpretでハンドラを作っているのですが、そこにスタブ関数を噛ませてやるだけです。

コード全体はここにあります。

おわりに

モックライブラリに開発にあたり、しばらくTemplate Haskellと戯れていたので、次はTemplete Haskellの記事を書いてもいいかもなぁと思いました。
もしくは、Elm, PureScript, Haskellそれぞれをやってみての所感とか。

なーんか思いついたらまた書きます。
ではまた。

Discussion