🐈

Haskellのモックライブラリを作りました

2024/08/03に公開
2

Haskellのモックライブラリを作りました!
https://github.com/pujoheadsoft/mockcat

Hackageはこちら
Stackageは今のところNightlyにのみ入ってます(これ放っておいてもいずれLTSにも入るのかな? →コメントで「入る」と教えていただきました)。

ざっくりとできること

大きく次の2つのことができます。

  • スタブ関数を作る
  • 引数が期待通り適用されたかを検証する

検証は色々と行えますが、大概のケースではスタブ関数を利用するだけで事足りると思っています。
詳しくはドキュメントをご参照ください。

特徴

既存のモックライブラリと比較したこのモックライブラリの特徴は、次かなと思います。

  • 元ネタなしにスタブ関数を作れる
  • スタブ関数はモナディックな値も、そうでない値も返せる

使い方

スタブ関数を作る

まずスタブ関数の作り方を書いておきます。

スタブ関数はcreateStubFnで作ることができます。
|>で連結された値が引数となります。連結された最後の値が返り値です。

例えばinputという文字列を適用したらoutputという文字列を返すスタブ関数を作るには次のようにします。

import Test.MockCat (createStubFn, (|>))

main :: IO ()
main = do
  f <- createStubFn $ "input" |> "output"
  print $ f "input" -- "output"

検証を行う

期待される引数が適用されたか検証するにはshouldApplyTo関数を使います。
この場合はcreateStubFn関数を直接使わず、まずcreateMock関数でモックを作る必要があります。
スタブ関数はstubFn関数でモックから取り出して使います。

{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE TypeApplications #-}
import Test.Hspec
import Test.MockCat

spec :: Spec
spec = do
  it "stub & verify" do
    -- モックの生成
    mock <- createMock $ "value" |> True
    -- スタブ関数の取り出し
    let stubFunction = stubFn mock
    -- assert
    stubFunction "value" `shouldBe` True
    -- 適用された引数の検証
    mock `shouldApplyTo` "value"

実例

使い方、書き味を理解してもらうため、実際の使用例をお見せします。

使用例として次を用います。

  • 単純な関数のテスト
  • 型クラスを使った関数のテスト
  • 型クラスを使った関数のテスト(HMock版)
  • 拡張可能作用(Polysemy※)を使った関数のテスト
    ※Polysemyというのは拡張可能作用のライブラリの中でもメジャーなものの一つです。

単純な関数のテスト

これは副作用を伴わないような純粋な関数のテストです。

{-# LANGUAGE BlockArguments #-}
module SimpleCaseSpec (spec) where
import Test.Hspec (Spec, it, shouldBe)
import Test.MockCat (createStubFn, (|>))

checkLength :: (String -> Bool) -> String -> String
checkLength isLong s =
  if isLong s
    then "Long enough."
    else "Too short."

spec :: Spec
spec = do
  it "if long" do
    f <- createStubFn $ "str" |> True
    checkLength f "str" `shouldBe` "Long enough."
  
  it "if short" do
    f <- createStubFn $ "str" |> False
    checkLength f "str" `shouldBe` "Too short."

解説します。
テスト対象はcheckLengthという関数です。

checkLength :: (String -> Bool) -> String -> String
checkLength isLong s =
  if isLong s
    then "Long enough."
    else "Too short."

checkLength関数はisLong関数にsを適用させ、その結果によって返す値を変えるという関数です。
このテストではisLongの内容には関心がないため、これはスタブ関数にしています。

-- "str"が適用されたら`True`を返すことだけを期待。ここでは内容はどうでもいい。
f <- createStubFn $ "str" |> True
checkLength f "str" `shouldBe` "Long enough."

型クラスを使った関数のテスト

次のようなテキストファイルの読み書きに関する型クラスがあったとします。

class (Monad m) => FileOperation m where
  readFile :: FilePath -> m Text
  writeFile :: FilePath -> Text -> m ()

そしてこの型クラスを利用するprogramという関数があるとします。

program :: (FileOperation m) =>
  FilePath ->
  FilePath ->
  (Text -> Text) ->
  m ()
program inputPath outputPath modifyText = do
  content <- readFile inputPath
  let modifiedContent = modifyText content
  writeFile outputPath modifiedContent

この関数は、ファイルを読み込んだ後、その内容を(Text -> Text)型の関数modifyTextに適用させ、結果を書き込む、という関数です。

この関数のテストを書くとして、確認したいことは以下になるでしょう。

  1. readFileinputPathが適用されていること
  2. modifyTextに1の結果が適用されていること
  3. writeFileoutputPathと2の結果が適用されていること

mockcatを使ってテストを書いてみます。

まずこんな感じでFileOperationのインスタンスを作ります。

data Functions = Functions
  { _readFile :: FilePath -> Text,
    _writeFile :: FilePath -> Text -> ()
  }

instance Monad m => FileOperation (ReaderT Functions m) where
  -- Functionsの関数に委譲するだけ
  readFile path = ask >>= \f -> pure $ f._readFile path
  writeFile path content = ask >>= \f -> pure $ f._writeFile path content

Functionsは処理を委譲する関数を集めたデータ型です。
そしてReaderT Functions mをインスタンスとすることでFunctionsの関数に処理を委譲させています。

準備ができたのでテストを書きます。さきほどのインスタンス定義があるので、あとはスタブ関数を作ってFunctionsを作り、runReaderTで実行するだけです。

spec :: Spec
spec = do
  it "Read, edit, and output files" do
    -- スタブ関数を作る
    readFileStub <- createStubFn $ "input.txt" |> pack "content"
    writeFileMock <- createMock $ "output.text" |> pack "modifiedContent" |> ()
    modifyContentStub <- createStubFn $ pack "content" |> pack "modifiedContent"

    -- Functionsに詰める
    let functions =
          Functions
            { _readFile = readFileStub,
              _writeFile = stubFn writeFileMock
            }

    -- 実行
    result <-
      runReaderT
        (program "input.txt" "output.text" modifyContentStub)
        functions

    -- 結果の検証
    result `shouldBe` ()

    writeFileMock `shouldApplyTo` ("output.text" |> pack "modifiedContent")

これで確認したかった3つが実現できました。

  1. readFileinputPathが適用されていること
  2. modifyTextに1の結果が適用されていること
  3. writeFileoutputPathと2の結果が適用されていること
コード全体
{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE OverloadedRecordDot #-}

module TypeClassSpec (spec) where

import Control.Monad.Reader (ReaderT, ask, runReaderT)
import Data.Text (Text, pack)
import Test.Hspec (Spec, it, shouldBe)
import Test.MockCat (createMock, createStubFn, stubFn, (|>), shouldApplyTo)
import Prelude hiding (readFile, writeFile)

class (Monad m) => FileOperation m where
  readFile :: FilePath -> m Text
  writeFile :: FilePath -> Text -> m ()

program ::
  (FileOperation m) =>
  FilePath ->
  FilePath ->
  (Text -> Text) ->
  m ()
program inputPath outputPath modifyText = do
  content <- readFile inputPath
  let modifiedContent = modifyText content
  writeFile outputPath modifiedContent

data Functions = Functions
  { _readFile :: FilePath -> Text,
    _writeFile :: FilePath -> Text -> ()
  }

instance Monad m => FileOperation (ReaderT Functions m) where
  readFile path = ask >>= \f -> pure $ f._readFile path
  writeFile path content = ask >>= \f -> pure $ f._writeFile path content

spec :: Spec
spec = do
  it "Read, edit, and output files" do
    readFileStub <- createStubFn $ "input.txt" |> pack "content"
    writeFileMock <- createMock $ "output.text" |> pack "modifiedContent" |> ()
    modifyContentStub <- createStubFn $ pack "content" |> pack "modifiedContent"

    let functions =
          Functions
            { _readFile = readFileStub,
              _writeFile = stubFn writeFileMock
            }

    result <-
      runReaderT
        (program "input.txt" "output.text" modifyContentStub)
        functions

    result `shouldBe` ()
    writeFileMock `shouldApplyTo` ("output.text" |> pack "modifiedContent")

しかし型クラスごとにいちいちFunctionsのような委譲する関数をまとめたデータ構造や、それを使うReaderTを作るのは面倒でしょう。
そこでモックライブラリHMockと組み合わせてみます。

型クラスを使った関数のテスト(HMock版)

上記のテストはHMockと組み合わせることでもっと簡単に書くことができます。
型クラスのスタブはHMockに任せて、mockcatは純粋な関数のスタブを担当します。

spec :: Spec
spec = do
  it "Read, edit, and output files" do
    modifyContentStub <- createStubFn $ pack "content" |> pack "modifiedContent"

    result <- runMockT $ do
      expect $ ReadFile "input.txt" |-> pack "content"
      expect $ WriteFile "output.text" (pack "modifiedContent") |-> ()

      program "input.txt" "output.text" modifyContentStub

    result `shouldBe` ()

HMockは型クラスのモックを作れるようにしてくれるライブラリです。
詳しくは述べませんが、TemplateHaskellにより型クラスを元にモックを生成してくれます。
しかしあくまで型クラスに定義された関数が対象なので、そうでないものはmockcatが担当するというわけです。

コード全体
{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
module TypeClassWithHMockSpec (spec) where

import Data.Text (Text, pack)
import Prelude hiding (writeFile, readFile)
import Test.Hspec (Spec, it, shouldBe)
import Test.MockCat (createStubFn, (|>))
import Test.HMock (expect, runMockT, makeMockable, (|->))

class Monad m => FileOperation m where
  readFile :: FilePath -> m Text
  writeFile :: FilePath -> Text -> m ()

makeMockable [t|FileOperation|]

program ::
  FileOperation m =>
  FilePath ->
  FilePath ->
  (Text -> Text) ->
  m ()
program inputPath outputPath modifyText = do
  content <- readFile inputPath
  let modifiedContent = modifyText content
  writeFile outputPath modifiedContent

spec :: Spec
spec = do
  it "Read, edit, and output files" do
    modifyContentStub <- createStubFn $ pack "content" |> pack "modifiedContent"

    result <- runMockT $ do
      expect $ ReadFile "input.txt" |-> pack "content"
      expect $ WriteFile "output.text" (pack "modifiedContent") |-> ()

      program "input.txt" "output.text" modifyContentStub

    result `shouldBe` ()

拡張可能作用(Polysemy※)を使った関数のテスト

次は型クラスでやってたことを拡張可能作用でやってみます。
拡張可能作用のライブラリとしてPolysemyを使います。

Polysemyの場合、型クラスではなく次のようにGADTsを使って関数を定義します。

data FileOperation m a where
  ReadFile :: FilePath -> FileOperation m Text
  WriteFile :: FilePath -> Text -> FileOperation m ()

一般的な拡張可能作用の場合、この後ボイラープレート的なコードを書く必要があるのですが、Polysemyはlow-boilerplateを謳っており、次のようにmakeSemとするだけでボイラープレート的なコードを生成してくれるので楽です。

makeSem ''FileOperation

プログラムは次のようになります。
型クラスのときはFileOperation mだった制約がMember FileOperation rになっていますが、それ以外は変わらないことがわかると思います。

program ::
  Member FileOperation r =>
  FilePath ->
  FilePath ->
  (Text -> Text) ->
  Sem r ()
program inputPath outputPath modifyText = do
  content <- readFile inputPath
  let modifiedContent = modifyText content
  writeFile outputPath modifiedContent

ちなみにreadFilewriteFileは、本来次のようなボイラープレートなコードを書く必要があった関数ですが、makeSemが生成してくれているので書かないで済んでいます。

readFile :: Member FileOperation r => FilePath -> Sem r Text
readFile path = send $ ReadFile path

writeFile :: Member FileOperation r => FilePath -> Text -> Sem r ()
writeFile path txt = send $ WriteFile path txt

テストコードは次のようになります。

spec :: Spec
spec = do
  it "Read, edit, and output files" do
    readFileStub <- createStubFn $ "input.txt" |> pack "content"
    writeFileMock <- createMock $ "output.text" |> pack "modifiedContent" |> ()
    modifyContentStub <- createStubFn $ pack "content" |> pack "modifiedContent"

    let runFileOperation :: Sem (FileOperation : r) a -> Sem r a
        runFileOperation = interpret $ \case
          ReadFile path -> pure $ readFileStub path
          WriteFile path text -> pure $ stubFn writeFileMock path text

    result <-
      program "input.txt" "output.text" modifyContentStub
        & runFileOperation
        & runResource
        & runM

    result `shouldBe` ()

    writeFileMock `shouldApplyTo` ("output.text" |> pack "modifiedContent")

mockcatでスタブ関数などを作っている箇所は、型クラスのテストとほとんどかわりないので差分を説明します。
まずrunFileOperationという関数ですが、これはいわゆるハンドラの部分にあたります。

let runFileOperation :: Sem (FileOperation : r) a -> Sem r a
    runFileOperation = interpret $ \case
      ReadFile path -> pure $ readFileStub path
      WriteFile path text -> pure $ stubFn writeFileMock path text

これは端的に言うと、Sem (FileOperation : r) a -> Sem r aというシグニチャからわかるように、FileOperationという作用を(処理を行うことで)取り除いて返す関数です。
「拡張可能」という言葉が示すように作用を複数持たせることができるのですが、その作用の数だけこういったハンドラが必要になります。
interpret関数の中でFileOperationのパターンに対応する処理が書かれていますが、今回はスタブ関数に処理を委譲しているだけです。返り値の型としてSem r aが期待されているため、pureを使っています。

次は実行の部分です。ここで先程作ったrunFileOperationを使い作用を除去します。このままだとまだSem r a型なので、更にrunMを使いm a型にしています。今回のmIOa()になります。

result <-
  program "input.txt" "output.text" modifyContentStub
    & runFileOperation
    & runM

あとは検証して終わりです。

result `shouldBe` ()

writeFileMock `shouldApplyTo` ("output.text" |> pack "modifiedContent")
コード全体
{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE PolyKinds #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeOperators #-}

module PolySemySpec (spec) where

import Data.Function ((&))
import Data.Text
import Polysemy (Members, Sem, interpret, makeSem, runM)
import Test.Hspec (Spec, it, shouldBe)
import Test.MockCat (createMock, createStubFn, stubFn, (|>), shouldApplyTo)
import Prelude hiding (readFile, writeFile)

data FileOperation m a where
  ReadFile :: FilePath -> FileOperation m Text
  WriteFile :: FilePath -> Text -> FileOperation m ()

makeSem ''FileOperation

program ::
  Member FileOperation r =>
  FilePath ->
  FilePath ->
  (Text -> Text) ->
  Sem r ()
program inputPath outputPath modifyText = do
  content <- readFile inputPath
  let modifiedContent = modifyText content
  writeFile outputPath modifiedContent

spec :: Spec
spec = do
  it "Read, edit, and output files" do
    readFileStub <- createStubFn $ "input.txt" |> pack "content"
    writeFileMock <- createMock $ "output.text" |> pack "modifiedContent" |> ()
    modifyContentStub <- createStubFn $ pack "content" |> pack "modifiedContent"

    let runFileOperation :: Sem (FileOperation : r) a -> Sem r a
        runFileOperation = interpret $ \case
          ReadFile path -> pure $ readFileStub path
          WriteFile path text -> pure $ stubFn writeFileMock path text

    result <-
      program "input.txt" "output.text" modifyContentStub
        & runFileOperation
        & runM

    result `shouldBe` ()

    writeFileMock `shouldApplyTo` ("output.text" |> pack "modifiedContent")

以上で使用例は終わりです。

なぜ作ったか

ここのところプライベートではPureScriptばっかり使ってきのですが、なぜいまHaskellでモックライブラリを作ったのかというとPolysemyがきっかけでした。

Extensible Effectsに関心を持っていた私は、Polysemyをちょっと触ってみたいなぁと思っていたんですね。

で、この間までやってた圏論の学習が一区切りついた(数学は底なし沼でいくらでも時間が溶けるので切り上げた)ので、キリもいいしちょっと触ってみるか、と。

触るとなると実用を考えたとき当然テストも必要になるし、テストファーストで書きたい(個人の意見)。
しかし私がやりたいことをすべて実現してくれるモックライブラリがなかったのです。
なので過去PureScriptでモックライブラリを作っていたし、移植するかくらいの気持ちで作りました。

作ってみて

上述のとおり文法の似たPureScriptでモックライブラリを作った経験があったので、それをベースにすれは割りと簡単にできるんじゃね?と思ったんですが、全然そんなことはなかったですね。

一番困ったのは、PureScriptにはあったInstance ChainがHaskellにはなかったことです。
要はインスタンス定義が重複したときの対処の仕方がPureScriptの方が楽でした。
ChatGPTに聞いたりしてましたが、これに関してはほぼ役に立たなかったですね。
コンパイラのバージョンによって、実行時例外が出たり出なかったりしたのも困りました。
(だからGithub Actionsでは複数バージョンのコンパイラでテストしている)

あとはHaskellが遅延評価だというところ。
スタブ関数は引数が適用されたとき、それを記録していってるのですが、返った値が評価されるまで記録も行われないため、結果を評価せずに捨ててしまうと適用してないことになってしまい、割と最初は戸惑いました(Haskell初心者ってこと)。

最後はライブラリを公開する先がHackageとStackageと2つあるところ。
これは困ったというか調べなきゃいけないことが増えて面倒だった感じです。

良かったと思うのは、PureScriptと比べて圧倒的に情報量が多いことですね。
それとライブラリ作成者からするとTemplate Haskellはいいなぁと思いました。

Discussion

YAMAMOTO YujiYAMAMOTO Yuji

Stackageは今のところNightlyにのみ入ってます(これ放っておいてもいずれLTSにも入るのかな?)。

入るはずです。

最後はライブラリを公開する先がHackageとStackageと2つあるところ。

もう調査済みかと思いますが、知らない人が見ると誤解を受けるかもしれないので補足しますと、StackageはあくまでHackageにアップロードされたパッケージのバージョンを指定しているだけなので、基本はあくまでもHackageです。確かに、最初はどちらにも登録する作業が発生しますが、Stackageについては一度登録してしまえばStackage上でビルドやテストが通らなくならない限り何もしなくていいですし。