この記事はHaskell Advent Calendar 2012の12月13日分です。
最近、 scottyを使ってWebアプリケーションを書いています。テストコードはHspecで書いています。ということでHspecでscottyのアプリケーションをテストする方法をざっくり説明してみようと思います。
文中のコード例の実際に動くものはここにあります。文中のコード例はimportやモジュール定義等を適宜省略していますのでご注意ください。
まずはscottyの基礎をちょっと確認しましょう。この記事で使われるアプリケーションは下記の通りです。
-- src/App.hs
app :: ScottyM ()
app = do
get "/" $
mustache "src/Views/index.mustache" $ indexView "Happy Holidays" "from Fujimura"
get "/foo" $
mustache "src/Views/foo.mustache" $ fooView "Foo" "Bar"
get "/bar" $
redirect "/foo"
sinatra系のシンプルなWebアプリケーションフレームワークを知っている人にはあまり説明はいらないかもしれません。このアプリは"/"でindex.mustache
を、"/foo"でfoo.mustache
の内容を表示して、 "/bar"は"foo"にリダイレクトします。mustache
はMustacheを使ってビューを描画してレスポンスを返します。ビューの詳細については省略します。redirect
は見ての通りリダイレクトです。その他の関数についてはドキュメントを見てみてください。
scottyはWAIベースなので、テストにはwai-testが使えます。wai-testはWAIアプリケーション用のテストフレームワークで、テストに便利な関数が入っています。
ここ数年、振る舞い駆動開発(Behavior Driven Development, BDD)が人気です。HspecはHaskellでBDDをするライブラリで、最も有名なBDDライブラリであるRSpecをベースに作られています。とてもよくデザインされていて、使うのは簡単です。BDDライブラリを使ったことがあれば、使い方はすぐわかると思います。
もしあなたがBDDについて知らない、もしくはやったことがないなら、Hspecで試してみる価値アリです。HaskellとBDDをの組み合わせは、Rubyプログラマーとして(僕は日中はRailsプログラマーです)、もしかしたらRubyとの組み合わせより良いんじゃないか?って思うくらいです。
ではテストコードを書いてみましょう。まずはレスポンスの本文にViewに渡した文字列が含まれてるか検証してみます。
-- test/AppSpec.hs
import qualified App
import qualified Data.ByteString.Lazy as LBS
import Helper
import qualified Network.Wai as W
import qualified Network.Wai.Test as WT
import Web.Scotty (scottyApp)
get :: W.Application -> BS.ByteString -> IO WT.SResponse
get app path =
WT.runSession (WT.srequest (WT.SRequest req "")) app
where req = WT.setRawPathInfo WT.defaultRequest path
main :: IO ()
main = hspec $ do
describe "GET /" $ do
it "should contain 'Hello' in response body" $ do
app <- liftIO $ scottyApp App.app
body <- WT.simpleBody <$> app `get` ""
body `shouldSatisfy` \x -> any (LBS.isPrefixOf "Happy Holidays") $ LBS.tails x
中身を説明します。
example(it
の部分)の中では、まずはさっきのコードにあったapp
からscottyAppを使って、WAIのapplicationを取得しています。
リクエスト送信は少し複雑です。なので、リクエストを送るためにget
というヘルパーを作りました。ヘルパーの詳細は省略します。実装の詳細に興味がある方は
wai-testのドキュメントを見てみてください。
レスポンスの本文はsimpleBodyで取れます。shouldSatisfyは検証したい値とそれに対する述語をとってアサーションを行います。
it
の中身がちょっと詳細を語りすぎている気がするので、ヘルパーを追加して、綺麗にしたいです。ということで下記のヘルパーを追加しました。
-- test/Helper.hs
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as LBS
import qualified Data.ByteString.Lazy.Char8 as LC8
import qualified Network.Wai as W
import qualified Network.Wai.Test as WT
get :: W.Application -> BS.ByteString -> IO WT.SResponse
get app path =
WT.runSession (WT.srequest (WT.SRequest req "")) app
where req = WT.setRawPathInfo WT.defaultRequest path
getBody :: WT.SResponse -> LBS.ByteString
getBody = WT.simpleBody
shouldContain :: LBS.ByteString -> LBS.ByteString -> Expectation
shouldContain subject matcher = assertBool message (subject `contains` matcher)
where
s `contains` m = any (LBS.isPrefixOf m) $ LBS.tails s
message =
"Expected \"" ++ LC8.unpack subject ++ "\" to contain \"" ++ LC8.unpack matcher ++ "\", but not"
さっき実装したget
はこちらに動かしました。getBody
はsimpleBodyに別名をつけただけです。shouldContain
はマッチャーで、xがyに入ってるかどうか確かめます。
実際のテストコードは下記のようにシンプルになりました。
-- test/AppSpec.hs
import qualified Data.ByteString.Lazy as LBS
import Helper
import qualified Network.Wai.Test as WT
import Web.Scotty (scottyApp)
main :: IO ()
main = hspec $ do
describe "GET /" $ do
it "should contain 'Happy Holidays' in response body" $ do
app <- liftIO $ scottyApp App.app
body <- getBody <$> app `get` ""
body `shouldContain` "Happy Holidays"
リダイレクトを検証するspecは下記のようになります。
-- test/AppSpec.hs
describe "GET /bar" $ do
it "should redirect to /foo" $ do
app <- getApp
res <- app `get` "bar"
res `shouldRedirectTo` "/foo"
はい。shouldRedirectTo
ってマッチャーを追加しました。実装は綺麗ともシンプルとも言えませんが、まあ動きます。
-- test/Helper.hs.hs
import Test.HUnit (assertBool, assertFailure)
import qualified Data.ByteString.Char8 as C8
import qualified Network.HTTP.Types as HT
import qualified Network.Wai.Test as WT
shouldRedirectTo :: WT.SResponse -> String -> Expectation
shouldRedirectTo response destination =
if getStatus response == 302
then failWith "Expected response to be a redirect but not"
else case lookup HT.hLocation $ WT.simpleHeaders response of
Just v -> assertBool
("Expected to redirect to \"" ++ destination ++ "\" but \"" ++ C8.unpack v ++ "\"")
(C8.unpack v == destination)
Nothing -> failWith "Invalid redirect response header"
この記事で使ったコードを下記にまとめておいたので、それを見てみてください。僕の知っている限りで今のところ一番良いとされている慣行におおよそ従おうとしています。執筆時点のHEADは5e96fc8b76でした。
https://github.com/fujimura/wai-hspec-example
HaskellのアプリケーションをBDDスタイルでテストするのは楽しいです。BDDのブラックボックステスト感とHaskellの優秀な型システムはバランスの良い組み合わせだと思います。
ちょっとまだこなれていない部分もありますが、WAIのアプリケーションにテストを書くのはそこまで大変ではありません。マッチャーが充実したらもっと楽になりそうです。
それではよいクリスマスを。
shouldContain
は、wai-testのassertBodyContainsで代用できます。この記事を書いている途中で気が付きました。