Haskell
test
Book
Web
stack

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

このエントリは 前回 に引き続き、本日(2017-9-7)発売の Haskell入門 へページ数の都合で載せられなかった記事に、加筆修正をして公開するものです。このエントリの内容は、10.6節の後に載せる予定だったものです。

今回テスト対象とするアプリケーションは、公開されている Haskell入門のサンプルコード を参照して下さい。 Haskell入門 の10章では、このアプリケーションの作り方を解説しています。

単体テスト

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

単体テストの実装

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

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

{-# 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
    )

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

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 の RequestResponse をそのままではなく、コンテンツ部分の扱いを単純にした SRequestSResponse という型を使ってWAIアプリケーションとやり取りします。最後に定義しているrequestはWAIアプリケーションとやり取りするためのラッパーです。リクエストを作るための引数と、レスポンスを受け取ってテストを実施するコールバックを受け取り、アプリケーションを呼び出した結果を元にコールバックを呼び出します。また、 lookup 関数によって Location HTTPヘッダがあるかを確認し、 Location ヘッダが送られてきている場合はリダイレクト先を get 関数で再び呼び出します。 getpostrequest アクションのラッパーであり、文字通りHTTPのGETメソッドとPOSTメソッドを使ったWAIアプリケーションの呼び出しを実現します。

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に想定する文字列が含まれるかをチェックしています。

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 の実行で単体テストを実行できます。

$ stack test

..略..

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

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