Haskell で書いた Web サービスにおける IO 部分の自動テスト
Haskell で書いた Web サービスの自動テストを考えたとき,IO の部分が問題になる場合があります. KVS や DBMS を利用する部分は CI サービス上で必要なものを起動すれば問題ないのですが,外部サービスと連携する部分は問題として残ります. またデグレするとユーザに直接影響を及ぼす部分については,IO であってもその動作を自動テスト化しておきたくなります.
こういった部分はモック化をサポートするライブラリを用いてテストすると思いますが*1,Haskell の場合はその辺どうするのだろうか?という疑問から調べて実験してみました.
なお,モック化のことを考えなければ既にある素晴らしいライブラリを利用してテストを書くことができます. この件についてはこちらの記事が大変参考になります.
先行事例
型クラスを利用したものと Free モナドを利用した事例がいくつかみつかります. 今回は型クラスによる分離を試みるため,その方針が紹介されていた記事を参考にさせてもらいました.
基本的な方針としては,IO が発生する操作を一般化した型クラスとして定義し,テストでは Reader
や State
モナドを用いてそれらを実装しています.
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
として IO
を lift
可能にしておきます.
-- 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
import
で Control.Monad.State
と Control.Monad.Trans.State
を間違えていることに気づかず,get
や put
の型が合わなくてしばらく悩んだりしました.