Help us understand the problem. What is going on with this article?

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

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

works-hi
「はたらく」を楽しく!に向けて大手企業の人事業務から変えていく HR業界のリーディングカンパニー
https://www.works-hi.co.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした