Haskell で書いた Web サービスにおける IO 部分の自動テスト

Haskell で書いた Web サービスの自動テストを考えたとき,IO の部分が問題になる場合があります. KVS や DBMS を利用する部分は CI サービス上で必要なものを起動すれば問題ないのですが,外部サービスと連携する部分は問題として残ります. またデグレするとユーザに直接影響を及ぼす部分については,IO であってもその動作を自動テスト化しておきたくなります.

こういった部分はモック化をサポートするライブラリを用いてテストすると思いますが*1Haskell の場合はその辺どうするのだろうか?という疑問から調べて実験してみました.

なお,モック化のことを考えなければ既にある素晴らしいライブラリを利用してテストを書くことができます. この件についてはこちらの記事が大変参考になります.

先行事例

型クラスを利用したものと Free モナドを利用した事例がいくつかみつかります. 今回は型クラスによる分離を試みるため,その方針が紹介されていた記事を参考にさせてもらいました.

基本的な方針としては,IO が発生する操作を一般化した型クラスとして定義し,テストでは ReaderState モナドを用いてそれらを実装しています.

class Monad m => MonadXxx m where
    operation :: ... -> m a

-- サービス
instance MonadXxx IO where
    operation ... = ...(IO のコード)...

-- テスト
newtype MockXxx m a = MockXxx { unMock :: StateT MockState m a } deriving ...

instance Monad m => MonadXxx (Mock m) where
    operation ... = ...(モック実装)...

run :: Monad m => MockXxx m a -> ... -> m a
run ... = ...

実験用 Web サービスのコード

実験用に小さな Web サービスを作成し,実際にモック化を試しました.Web サービスフレームワークには servant を使用しています.

この Web サービスで定義した API以下の 3 つです.

  • POST /register
    • ユーザ情報を新規作成し,登録完了メールを送信する
  • POST /login
    • 最新のログイン時刻を更新し,ログイン成功メールを送信する
  • GET /users/:id
    • ユーザ情報を取得する,キャッシュを使用する

今回は外部サービスとの連携部分をメール送信処理で代用しています.

IO 発生箇所の分離

基本方針は先行事例と同じですが,操作の内容に応じて分割定義しました. サービスはそれらをまとめる形で実装しています.

(※ この命名が慣習に従ったものなのかどうかは……自信がありません)

サービスとしての実装

まずは先の型クラスを IO に対して実装します.

-- src/WS/Cache.hs

instance MonadCache IO where
    get key = do
        ...(ここはいつも通り書く)...

またこれらをまとめた App m を定義し,servant のハンドラにはこの制約を付けます.

-- src/WS/App.hs

class (MonadCatch m, MonadMail m, MonadCache m, MonadDB m) => App m where
    ...(サービス固有の関数を定義したり)...

register :: App m => RegForm -> m User      -- こんな感じで型クラスを実装したら切り替えられるようにしておく
...

これで IO に対する実装があれば上記の register を servant のハンドラとして実行できるようになります.

テスト用のモックと型クラスの実装

MockApp m a が今回のモックです. テスト実行中の状態を保存したいため State を使用しています. また今回はテストに SQLite を使用するため部分的に IO が発生します*2. なので StateT として IOlift 可能にしておきます.

-- test/Spec.hs

newtype MockApp m a = MockApp
    { app :: StateT MockAppState m a }
  deriving (Functor, Applicative, Monad, MonadTrans, MonadThrow, MonadCatch)

あとはこのモックに対して型クラスを実装していきます. このとき State に持たせるデータを調整することで,戻り値を自由に変えるだけで無く呼び出し履歴の記録といったことも可能です.

-- test/Spec.hs

instance (Functor m, MonadCatch m) => WS.MonadCache (MockApp m) where
    get k = do
        S.modify $ saveHistory "Cache.get"
        s <- S.get
        return $ decode' <$> Map.lookup k (cache s)
        ...

テストの実装

テストの記述には Hspec を使用しています. テストコードのうち,モックを使用した部分は registerSpec です*3

実行するときは初期状態 initState とテストしたい servant のハンドラを渡して以下のように実行します.

-- test/Spec.hs

registerSpec :: Spec
registerSpec =
    describe "POST /register" $
        it "mock registration" $ do
            curr <- getPOSIXTime
            let form = WS.RegForm (pack $ "user-" ++ show curr) (pack $ "user-" ++ show curr ++ "@localhost")

            (res, state) <- runMock (WS.register form) initState    -- モックで実行する

            WS.name res `shouldBe` WS.regName form
            emails state `shouldNotSatisfy` null
            (addressEmail . head . mailTo . head . emails $ state) `shouldBe` WS.regEmailAddress form
            history state `shouldBe` ["DB.insert", "DB.select", "Mail.sendMail"]    -- 最後にメールを送信しているかチェック

サービスとしては IO で実行される部分が,テストでは MockApp m としてモック化した状態で実行されます.

おわりに

基本に従って実装すれば,IO をモック化した自動テストはうまくいきそうだなという感覚は得られました.

  • IO が発生する箇所を限定する
  • 実装の粒度とモック化のしやすさを考えながら分離面を決める

また Free モナドを利用した方法もあるようですが,こちらの検討についてはまた今度.

おまけ: その他いろいろ

使用したフレームワークとライブラリ

servant のエラー処理

今回のようにハンドラ全体が IO になっている場合,その内部からエラーを投げたいときにどうすれば良いのか迷いました. 今回は throwM で外までぶん投げてから catch し,Either.left し直しています.

transfomers

importControl.Monad.StateControl.Monad.Trans.State を間違えていることに気づかず,getput の型が合わなくてしばらく悩んだりしました.

*1:他の言語を使っているときは実際そうしています

*2:モック実装を楽にしたかったので.ただローカルに閉じているため今回の主旨には反しないと思います.あと HRR をご存知の方はお気づきかもしれませんが,今回のコードだと厳密には MySQL を分離し切れていません.

*3:他の Spec は wai のテストを試したくて実装したものであり,今回の件とは関係ありません