Skip to content

Instantly share code, notes, and snippets.

@fujimura
Created December 13, 2012 15:45
Show Gist options
  • Save fujimura/4277295 to your computer and use it in GitHub Desktop.
Save fujimura/4277295 to your computer and use it in GitHub Desktop.
HspecでWAIのアプリケーションをテストしましょう (Haskell Advent Calendar 2012 12/13)

HspecでWAIのアプリケーションをテストしましょう

この記事はHaskell Advent Calendar 2012の12月13日分です。

イントロ

最近、 scottyを使ってWebアプリケーションを書いています。テストコードはHspecで書いています。ということでHspecでscottyのアプリケーションをテストする方法をざっくり説明してみようと思います。

文中のコード例の実際に動くものはここにあります。文中のコード例はimportやモジュール定義等を適宜省略していますのでご注意ください。

scottyの基礎

まずは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"にリダイレクトします。mustacheMustacheを使ってビューを描画してレスポンスを返します。ビューの詳細については省略します。redirectは見ての通りリダイレクトです。その他の関数についてはドキュメントを見てみてください。

scottyはWAIベースなので、テストにはwai-testが使えます。wai-testはWAIアプリケーション用のテストフレームワークで、テストに便利な関数が入っています。

Hspecについて少し

ここ数年、振る舞い駆動開発(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はこちらに動かしました。getBodysimpleBodyに別名をつけただけです。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で代用できます。この記事を書いている途中で気が付きました。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment