Haskellのモックライブラリを作りました
Haskellのモックライブラリを作りました!
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
に適用させ、結果を書き込む、という関数です。
この関数のテストを書くとして、確認したいことは以下になるでしょう。
-
readFile
にinputPath
が適用されていること -
modifyText
に1の結果が適用されていること -
writeFile
にoutputPath
と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つが実現できました。
-
readFile
にinputPath
が適用されていること -
modifyText
に1の結果が適用されていること -
writeFile
にoutputPath
と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
ちなみにreadFile
やwriteFile
は、本来次のようなボイラープレートなコードを書く必要があった関数ですが、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
型にしています。今回のm
はIO
でa
は()
になります。
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
入るはずです。
もう調査済みかと思いますが、知らない人が見ると誤解を受けるかもしれないので補足しますと、StackageはあくまでHackageにアップロードされたパッケージのバージョンを指定しているだけなので、基本はあくまでもHackageです。確かに、最初はどちらにも登録する作業が発生しますが、Stackageについては一度登録してしまえばStackage上でビルドやテストが通らなくならない限り何もしなくていいですし。
なるほど、理解しました。ありがとうございます!