WAIアプリケーションの単体テスト - Haskell入門より

このエントリは [前回](https://qiita.com/hiratara/items/378e14b5d200fb8838fd) に引き続き、本日(2017-9-7)発売の [Haskell入門](http://gihyo.jp/book/2017/978-4-7741-9237-6) へページ数の都合で載せられなかった記事に、加筆修正をして公開するものです。このエントリの内容は、10.6節の後に載せる予定だったものです。

今回テスト対象とするアプリケーションは、公開されている [Haskell入門のサンプルコード](https://github.com/hiratara/Haskell-Nyumon-Sample/tree/master/chap10-samples) を参照して下さい。 [Haskell入門](http://gihyo.jp/book/2017/978-4-7741-9237-6) の10章では、このアプリケーションの作り方を解説しています。

## 単体テスト

WAIにはWAIアプリケーションをテストするための`Network.Wai.Test`というモジュールが用意されています。本節では`Network.Wai.Test`の簡単な使い方を紹介するために、Weight Recorderアプリケーションの単体テストを作成します。未登録のユーザがWeight Recorderアプリケーションへアクセスし、ユーザ登録、ログイン、体重の登録といった操作をすべて呼び出せるかを確認します。

### 単体テストの実装

`test/test.hs` へ単体テストを記述します。ここでは、テストの実行はシンプルに`Test.HUnit`モジュールの`runTestTT`を使うことにしましょう。

`import` 文の一覧は次の通りです。

```haskell
{-# LANGUAGE OverloadedStrings #-}

module Main (main) where

import           Control.Monad             (void)
import qualified Data.ByteString           as BS
import qualified Data.ByteString.Lazy      as LBS
import qualified Data.ByteString.Lazy.UTF8 as LBS
import qualified Network.HTTP.Types        as HTTP
import qualified Network.Wai               as WAI
import qualified Network.Wai.Test          as WT
import           Paths_weight_recorder     (getDataDir)
import           System.FilePath           ((</>))
import           System.IO.Temp            (withSystemTempFile)
import           System.Process            (callCommand)
import           Test.HUnit
    ( Assertion
    , Test (TestCase)
    , runTestTT
    )
import           Web.Spock                 (spockAsApp)
import           Web.WeightRecorder
    ( WRConfig (WRConfig, wrcDBPath, wrcPort, wrcTplRoots)
    , weightRecorderMiddleware
    )
```

`testWeightRecorder`は`Assertion`型で、これが実行されるテストです。Weight Recorderアプリケーションの実行にはSQLiteのデータベースが必要なので、まずはそれを作ります。`withSystemTempFile`関数によって空の一時ファイルを作成し、`callCommand` I/Oアクションで`sqlite3` コマンドにスキーマ定義ファイルを渡して呼び出すことで初期化します。作成した一時ファイルをDBとしてSpockアプリケーションを作成しますが、ここではWebアプリケーションサーバを起動する必要はありません。代わりに `spockAsApp` 関数を使い、WAIミドルウェアからWAIアプリケーションに変換しています。 `runSession` I/OアクションにテストとWAIアプリケーションを渡すと、そのアプリケーションを対象としてテストを実施します。テストは`Session`モナドで記述します。

```haskell
main :: IO ()
main = void $ runTestTT (TestCase testWeightRecorder)

sqlFile :: IO FilePath
sqlFile = do
    datadir <- getDataDir
    return $ datadir </> "data" </> "schema.sql"

testWeightRecorder :: Assertion
testWeightRecorder =
    withSystemTempFile "test.db" $
    \path _ ->
         do sql <- sqlFile
            callCommand $ "sqlite3 " ++ path ++ " < " ++ sql
            datadir <- getDataDir
            let cfg =
                    WRConfig
                    { wrcDBPath = path
                    , wrcTplRoots = [datadir </> "templates"]
                    , wrcPort = 9999 -- Won't use this value while testing
                    }
                m = weightRecorderMiddleware cfg
            WT.runSession basic =<< spockAsApp m
```

`Network.Wai.Test` モジュールはテストを記述するためのモジュールであり、`wai-extra`パッケージに定義されています。テストの記述に用いる`Session`モナドは、指定したWAIアプリケーションの呼び出しを提供します。また、HTTPクッキーを保持し、WAIアプリケーションにアクセスする場合には適切にクッキーを送ってくれます。Weight Recorderアプリケーションのようにセッションを扱うWAIアプリケーションをテストする際に、大変便利な機能です。`Network.Wai.Test` では簡単のため、 WAI の `Request` と `Response` をそのままではなく、コンテンツ部分の扱いを単純にした `SRequest` と `SResponse` という型を使ってWAIアプリケーションとやり取りします。最後に定義している`request`はWAIアプリケーションとやり取りするためのラッパーです。リクエストを作るための引数と、レスポンスを受け取ってテストを実施するコールバックを受け取り、アプリケーションを呼び出した結果を元にコールバックを呼び出します。また、 `lookup` 関数によって `Location` HTTPヘッダがあるかを確認し、 `Location` ヘッダが送られてきている場合はリダイレクト先を `get` 関数で再び呼び出します。 `get` と `post` は `request` アクションのラッパーであり、文字通りHTTPのGETメソッドとPOSTメソッドを使ったWAIアプリケーションの呼び出しを実現します。

```haskell
get :: BS.ByteString -> (WT.SResponse -> WT.Session ()) -> WT.Session ()
get url = request HTTP.methodGet [] url ""

post
    :: BS.ByteString
    -> LBS.ByteString
    -> (WT.SResponse -> WT.Session ())
    -> WT.Session ()
post url body = request HTTP.methodPost postHeaders url body
  where
    postHeaders = [(HTTP.hContentType, "application/x-www-form-urlencoded")]

request
    :: HTTP.Method
    -> [HTTP.Header]
    -> BS.ByteString
    -> LBS.ByteString
    -> (WT.SResponse -> WT.Session ())
    -> WT.Session ()
request meth hs url body cb = do
    let req =
            WT.defaultRequest
            { WAI.requestMethod = meth
            , WAI.requestHeaders = hs
            } `WT.setPath`
            url
    res <- WT.srequest (WT.SRequest req body)
    case lookup HTTP.hLocation (WT.simpleHeaders res) of
        Nothing -> cb res
        Just url' -> get url' cb
```

`basic` がテストの詳細を記述したものです。最初に書いたシナリオに沿って一通りWeight Recorderアプリケーションにアクセスし、 `assertBodyContains` アクション を使って戻されたHTMLに想定する文字列が含まれるかをチェックしています。

```haskell
basic :: WT.Session ()
basic = do
    get "/" $
        \res ->
             do bodyContains "ユーザ登録" res
                bodyContains "ログイン" res
    post "/register" "name=hoge&password=hage" $ bodyContains "登録しました"
    post "/login" "name=hoge&password=hage" $ bodyContains "体重の入力"
    post "/new_record" "weight=60.1" $ bodyContains "60.1"
  where
    bodyContains = WT.assertBodyContains . LBS.fromString
```

### 単体テストの実行

それでは、Stackを使って単体テストを呼び出します。 `weight-recorder.cabal` にテストをビルドするための記述を追加します。

```
test-suite weight-recorder-test
  type:                exitcode-stdio-1.0
  hs-source-dirs:      test
  main-is:             test.hs
  build-depends:       base
                     , bytestring
                     , directory
                     , filepath
                     , http-types
                     , HUnit
                     , temporary
                     , utf8-string
                     , wai
                     , wai-extra
                     , weight-recorder
                     , process
                     , Spock
  ghc-options:         -Wall
  default-language:    Haskell2010
```

`stack test` の実行で単体テストを実行できます。

```shell
$ stack test

..略..

weight-recorder-0.1.0.0: test (suite: weight-recorder-test)

Cases: 1  Tried: 1  Errors: 0  Failures: 0
```