Haskellで副作用をモックしてテストを書く
どのような言語においても、テストを書こうと思ったときの悩みの種は、その9割方がIO等の副作用をどう扱うかだ。そしてその解決策は、DBへの接続や設定の読み込みなどをなるべく一か所に集め、その他の処理は純粋でテストしやすいよう維持するといったことである。
上の問題と解決策は、純粋関数型言語であるHaskellでも変わらない。Haskellはその設計思想上テストを簡単に書くことができるといわれるが、純粋なコードと副作用を含むコードとを型で見分けることができるという大きなアドバンテージはあるものの、実際には副作用を含むコードのテストが困難なことに変わりはない。
この記事では、Haskellで副作用の混ざったコードをいかにテストするか、特にIO
モナドから副作用を切り出すことで、最終的にこれをモックしてテストを行う方法を紹介する。
TL;DR
-
IO
モナドをテストすることはできない - 型クラスを使って副作用を
IO
モナドから切り出す - 副作用をモックするモナドを作ってテストを書く
IOモナドをテストすることはできない
IO
モナドは直観的には状態がRealWorld
であるようなState
モナドであり、RealWorld
はmain
を実行する際にプログラムを実行する側によって渡される。そのため、RealWorld
の状態を変更するようなプログラム(=副作用を伴う処理)をテストすることは不可能だ。
それでは、例えば下のprettyPrint
関数をテストしたいと思ったら、どのようにすればいいのだろうか。
prettyPrint :: MyObj -> IO ()
はじめに思いつくのは、この関数内のprettyPrintImpl :: MyObj -> String
な部分を切り出してきて、それをテストすることである。これで解決するならばそれがベストだが、例えば内部で設定を読み込んで出力方式を変えていたらどうだろうか? 副作用というものは導入するのは非常に簡単で、振り切るのは非常に難しいものだ。
これを解決するために、prettyPrint
の副作用を型クラスに切り出し、そのメソッドが使える任意の型をprettyPrint
持つようにする。
型クラスを使って副作用を切り出す
prettyPrint
の持つ副作用がDBからの設定の読み出しと標準出力だとしよう。それぞれの副作用を、MonadReadDB
とMonadStdout
に切り出してみる。
prettyPrint :: (Monad m, MonadReadDB m, MonadStdout m) => MyObj -> m ()
class MonadReadDB m where
readSettings :: m Settings
class MonadStdout m where
putStrLnM :: String -> m ()
次に、IO
モナドでの実装を書く。副作用はここでprettyPrint
から切り離される。
instance MonadReadDB IO where
readSettings = readSettingsImpl
instance MonadStdout IO where
putStrLnM = putStrLn
これでprettyPrint
はIO
から切り離されてテスト可能な関数になった。
余談だが、pettyPrint
の呼び出し元を変更する必要はない。なぜなら、prettyPrint
の呼び出し元はIO
の文脈であるはずであり、IO
の文脈でprettyPrint
を呼び出せば自動的に上記IO
の実装が選択されるからだ。
テストのためにモックモナドを作ろう
MonadReadDB
とMonadStdout
を実装したモナドさえ書いてしまえば、純粋な文脈でprettyPrint
を実行することができる。
モナドを自作するというと大仰なことに聞こえるが、ほとんどのケースでは簡単に作ることができる。
ここでは、prettyPrint
を実行できるモックモナドを次のように表現することにした。設定をReader
モナドから読み出し、出力をWriter
モナドに書き出す、簡単な仕組みだ。
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
data Settings = Settings -- whatever you like
newtype MockT m a = MockT (WriterT String (ReaderT Settings m) a)
deriving (Functor, Applicative, Monad)
runMockT :: MockT m a -> Settings -> m (a, String)
runMockT (MockT w) = runReaderT $ runWriterT w
runMock :: MockT Identity a -> Settings -> (a, String)
runMock = fmap runIdentity . runMockT
instance MonadTrans MockT where
lift = MockT . lift . lift
このモナドを使って、MonadReadDB
とMonadStdout
を以下のように実装する。
instance Monad m => MonadReadDB (MockT m) where
readSettings = MockT $ lift $ ask
instance Monad m => MonadStdout (MockT m) where
putStrLnM s = MockT $ tell $ s <> "\n"
以上でprettyPrint
のテストが書けるようになった。実際のテストでは以下のようにしてSettings
と引数を渡し、標準出力を得ることができる。
spec :: Monad m => m ((), String)
spec = flip runMockT Settings $
prettyPrint "something1"
まとめ
Haskellでは、副作用を型クラスに切り出すことで副作用をモックすることができる。
副作用が多数絡んだ関数はモックを行うのも大変だが、
それゆえIO
モナドを扱うときはなるべく副作用を意識して処理を分けて書くとよい。