IOアクションひとつひとつを利用許諾し・テスト可能にする

IOのテストってたいへんだ.これをどうにかするために,先人たちは色々考え,

  • 利用対象の特定IOアクションを慎重に取捨選択し,MonadHogeIOのような型クラスによりインターフェースを与えてからそのインターフェース上のアクションとして実装する.実行時は目的のインスタンスの上で走らせるし,テスト時はMock用インスタンスの上で走らせる
  • 同様に特定IOアクションを慎重に取捨選択し,FreeモナドやOperationalモナドによりインターフェースを与えて以下略.目的のランナーで走らせたり,テスト用のランナーで走らせたりする

といった手段を取る.

で,普通テスト用のものはピュアになるように実装し,テストしやすくする.具体的には,型クラスとモナドと Free モナド - あどけない話や,Freeモナドを超えた!?operationalモナドを使ってみよう - モナドとわたしとコモナドがわかりやすいかな.

でも,どちらの方法を取るにせよ,IOアクションが適切に取捨選択された状態になければならない.IOアクションaとIOアクションbだけ使うIOアクションx (x = a >> b)と,IOアクションbとIOアクションcだけ使うIOアクションy (y = b >> c)があるとき,a,b,cそれぞれに相当するインターフェースを持った型クラスやらデータ型のコンストラクタやらを用意することになる.もくは,aとbだけ,bとcだけにそれぞれ相当するやつだけ持ったものを用意するだろうか?いずれにせよ,特定のものに限定した環境を作ってその上で戦うことになる.というのがこのあたりの話

で,この記事の本題.上のツイートで「制限されてなければ(義務感)」みたいに自分でも言っておいて,果たして今でも本当にそんな状態なのかとちょろっと考えてみた結果を簡単にライブラリ(まだhackageに上げてない)にしてみた.

notogawa/genjitsu · GitHub

このライブラリは,ザックリ言うとIOアクションの利用制限及びモック差し替えを自由に行うための,インターフェース・型クラス制約生成系及び,それを利用した例としてのPreludeの置き換えモジュールとなっている.

TemplateHaskellにより任意のIOアクションからextensible-effectsのEffアクションを生成し,あるIOアクションに対応して生成された特定の型クラス制約がコンテキストに無い場所ではそのIOアクションを元に生成されたEffアクションは使えないという制約を付けることができる.加えて,Effアクションがコンテキストに持つ型クラス制約に対するインターフェースだけをモック実装すればそのEffアクションはテスト可能になる.というものだ.

自分で上手く言えてないのわかってるのでたぶん何言ってるかわからないと思う.さっさとサンプル示そう.

まず,IOアクションからEffアクションを生成するTemplateHaskellについてだが,たとえば,Control.Genjitsu.THのgenjitsuを使い,

import qualified Prelude as P
$(genjitsu 'P.putStrLn)

のようにすると,これにより次のようなコードが生成される.

class AllowPutStrLn m where
    allowPutStrLn :: String -> m a

instance AllowPutStrLn IO where
    allowPutStrLn = putStrLn

putStrLn :: ( Typeable1 m
            , AllowPutStrLn m
            , Member (Lift m) r
            , SetMember Lift (Lift m) r) =>
            String
         -> Eff r ()
putStrLn x = lift (allowPutStrLn x)

Prelude.Genjitsuモジュールは,これをPreludeのIOアクション全てに行い,元の名前の関数を新たに生成された同じ名前の関数で置き換えているだけだ.

実際にPrelude.Genjitsuを使うと次のようなコードになる.

sample1 :: ( Typeable1 m
           , AllowReadLn m    -- readLnが使える
           , AllowPutStrLn m  -- putStrLnが使える
           , Member (Lift m) r
           , SetMember Lift (Lift m) r
           ) =>
           Eff r ()
sample1 = do
  n <- readLn
  let s = n + 1 :: Int
  -- putStr "is denied"
  putStrLn (show s)

sample1は型の部分に目を潰ると,中身はただのIOモナドのように見える.ただし,このsample1のコンテキストでは,readLnとputStrLnしか許されていない.そのため,readLnとputStrLnは使えるが,putStrを使おうとするとコンパイルエラーになる.

また,別のケースも見てみよう.

sample2 :: ( Typeable1 m
           , AllowPutStr m    -- putStrが使える
           , AllowPutStrLn m  -- putStrLnが使える
           , Member (Lift m) r
           , SetMember Lift (Lift m) r
           ) =>
           Eff r ()
sample2 = do
  putStr "Start..."
  putStrLn "Done"

こちらのsample2では,putStrが使えるようにコンテキスト指定されているのでputStrが使える.

さて,これらを実際にIOアクションに還元してあげるのは簡単だ.extensible-effectsのLiftを使っているのでrunLiftしてあげればいい.

sample1IO :: IO ()
sample1IO = runLift sample1

sample2IO :: IO ()
sample2IO = runLift sample2

また,sample1/sample2に対してテストを書く場合,これらの各コンテキストにある型クラス制約で許可されたものだけインスタンス実装すればいい.たとえば,sample1をテストするのにStateモナドを使ってみると,

-- 各自適切なGHC拡張は補完してネ
newtype Mock1 a = Mock1 { runMock1 :: State String a } 
    deriving ( Functor, Applicative
             , Monad, MonadState String, Typeable)

-- sample1はAllowReadLnをコンテキストに持つのでMock実装が要求されている
instance AllowReadLn   Mock1 where
    allowReadLn   = fmap read get

-- sample1はAllowPutStrLnをコンテキストに持つのでMock実装する
instance AllowPutStrLn Mock1 where
    allowPutStrLn = put

test1 = ((),"3") == runState (runMock1 $ runLift sample1) "2"

sample2のほうも同様にしてpureなテストを書くことができる.sample2についてはsample1と異なりテスト対象として入力のアクションを含まないのでWriterモナドでモック実装してあげれば十分だろう.ここまでのサンプルコード

genjitsuがMonadHogeIOインターフェースやFree/Operationalモナド方式に対して持つ利点は,

  • 必要なクラスやらの生成がTemplateHaskellでだいぶラク
    • runLiftでIOアクションに戻したものを再度genjitsuしておくようなことも簡単
  • あらかじめ利用したいアクションを取捨選択して制限しておく必要が無い
    • その場その場で本当に必要ならコンテキストとして与えればよい
  • アクション単位の利用許可を型で表すことができる
    • ウカツにいろんな種類のIOアクションを使ってしまうようなことを防げる
  • 何を実装すれば走らせることができるかが型レベルで明確になる
    • 許可されているものに対してのみ実装を与えれば走る
    • モック書いたりするときに着目しているテスト対象に対して余計なインターフェースを気にしなくてよい

とか.そのへん.

感覚的にだいぶ楽だと思うんだけどどうだろうか?正直なところ言うとコンセプトにイマイチ自信が無いのでhackageには上げてない.