LoginSignup
7
1

More than 3 years have passed since last update.

Haskellで副作用をモックしてテストを書く

Posted at

Haskellで副作用をモックしてテストを書く

 どのような言語においても、テストを書こうと思ったときの悩みの種は、その9割方がIO等の副作用をどう扱うかだ。そしてその解決策は、DBへの接続や設定の読み込みなどをなるべく一か所に集め、その他の処理は純粋でテストしやすいよう維持するといったことである。
 上の問題と解決策は、純粋関数型言語であるHaskellでも変わらない。Haskellはその設計思想上テストを簡単に書くことができるといわれるが、純粋なコードと副作用を含むコードとを型で見分けることができるという大きなアドバンテージはあるものの、実際には副作用を含むコードのテストが困難なことに変わりはない。
 この記事では、Haskellで副作用の混ざったコードをいかにテストするか、特にIOモナドから副作用を切り出すことで、最終的にこれをモックしてテストを行う方法を紹介する。

TL;DR

  1. IOモナドをテストすることはできない
  2. 型クラスを使って副作用をIOモナドから切り出す
  3. 副作用をモックするモナドを作ってテストを書く

IOモナドをテストすることはできない

 IOモナドは直観的には状態がRealWorldであるようなStateモナドであり、RealWorldmainを実行する際にプログラムを実行する側によって渡される。そのため、RealWorldの状態を変更するようなプログラム(=副作用を伴う処理)をテストすることは不可能だ。
 それでは、例えば下のprettyPrint関数をテストしたいと思ったら、どのようにすればいいのだろうか。

prettyPrint :: MyObj -> IO ()

 はじめに思いつくのは、この関数内のprettyPrintImpl :: MyObj -> Stringな部分を切り出してきて、それをテストすることである。これで解決するならばそれがベストだが、例えば内部で設定を読み込んで出力方式を変えていたらどうだろうか? 副作用というものは導入するのは非常に簡単で、振り切るのは非常に難しいものだ。
 これを解決するために、prettyPrintの副作用を型クラスに切り出し、そのメソッドが使える任意の型をprettyPrint持つようにする。

型クラスを使って副作用を切り出す

 prettyPrintの持つ副作用がDBからの設定の読み出しと標準出力だとしよう。それぞれの副作用を、MonadReadDBMonadStdoutに切り出してみる。

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

 これでprettyPrintIOから切り離されてテスト可能な関数になった。
 余談だが、pettyPrintの呼び出し元を変更する必要はない。なぜなら、prettyPrintの呼び出し元はIOの文脈であるはずであり、IOの文脈でprettyPrintを呼び出せば自動的に上記IOの実装が選択されるからだ。

テストのためにモックモナドを作ろう

 MonadReadDBMonadStdoutを実装したモナドさえ書いてしまえば、純粋な文脈で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

 このモナドを使って、MonadReadDBMonadStdoutを以下のように実装する。

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モナドを扱うときはなるべく副作用を意識して処理を分けて書くとよい。

7
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
1