Further yesod-test cleanup

This commit is contained in:
Michael Snoyman 2013-04-02 17:05:40 +03:00
parent ba8706429a
commit ad817275e8

View File

@ -24,7 +24,7 @@ backend pre-conditions, or to assert that your session is having the desired eff
module Yesod.Test ( module Yesod.Test (
-- * Declaring and running your test suite -- * Declaring and running your test suite
yesodSpec, YesodSpec, YesodSpecM, YesodSpecTree (..), ydescribe, yit, yesodSpec, YesodSpec, YesodSpecTree (..), ydescribe, yit,
-- * Making requests -- * Making requests
-- | To make a request you need to point to an url and pass in some parameters. -- | To make a request you need to point to an url and pass in some parameters.
@ -53,6 +53,7 @@ module Yesod.Test (
-- * Grab information -- * Grab information
getTestYesod, getTestYesod,
getResponse,
-- * Utils for debugging tests -- * Utils for debugging tests
printBody, printMatches, printBody, printMatches,
@ -84,7 +85,6 @@ import Control.Monad.IO.Class
import System.IO import System.IO
import Yesod.Test.TransversingCSS import Yesod.Test.TransversingCSS
import Yesod.Core (toWaiAppPlain, YesodDispatch) import Yesod.Core (toWaiAppPlain, YesodDispatch)
import Data.Monoid (mappend)
import qualified Data.Text.Lazy as TL import qualified Data.Text.Lazy as TL
import Data.Text.Lazy.Encoding (encodeUtf8, decodeUtf8) import Data.Text.Lazy.Encoding (encodeUtf8, decodeUtf8)
import Text.XML.Cursor hiding (element) import Text.XML.Cursor hiding (element)
@ -96,21 +96,54 @@ import qualified Web.Cookie as Cookie
import qualified Blaze.ByteString.Builder as Builder import qualified Blaze.ByteString.Builder as Builder
import Data.Time.Clock (getCurrentTime) import Data.Time.Clock (getCurrentTime)
-- | The state used in a single test case defined using 'it' -- | The state used in a single test case defined using 'yit'
data OneSpecData site = OneSpecData Application site Cookies (Maybe SResponse) --
-- Since 1.2.0
data YesodExampleData site = YesodExampleData
{ yedApp :: !Application
, yedSite :: !site
, yedCookies :: !Cookies
, yedResponse :: !(Maybe SResponse)
}
-- | A single test case, to be run with 'yit'.
--
-- Since 1.2.0
type YesodExample site = ST.StateT (YesodExampleData site) IO
-- | Mapping from cookie name to value.
--
-- Since 1.2.0
type Cookies = M.Map ByteString Cookie.SetCookie
-- | Corresponds to hspec\'s 'Spec'.
--
-- Since 1.2.0
type YesodSpec site = Writer [YesodSpecTree site] ()
-- | Internal data structure, corresponding to hspec\'s 'YesodSpecTree'.
--
-- Since 1.2.0
data YesodSpecTree site
= YesodSpecGroup String [YesodSpecTree site]
| YesodSpecItem String (YesodExample site ())
-- | Get the foundation value used for the current test. -- | Get the foundation value used for the current test.
-- --
-- Since 1.2.0 -- Since 1.2.0
getTestYesod :: YesodExample site site getTestYesod :: YesodExample site site
getTestYesod = do getTestYesod = fmap yedSite ST.get
OneSpecData _ site _ _ <- ST.get
return site
-- | The OneSpec state monad is where 'yit' runs. -- | Get the most recently provided response value, if available.
type YesodExample site = ST.StateT (OneSpecData site) IO --
-- Since 1.2.0
getResponse :: YesodExample site (Maybe SResponse)
getResponse = fmap yedResponse ST.get
data RequestBuilderData = RequestBuilderData [RequestPart] (Maybe SResponse) data RequestBuilderData = RequestBuilderData
{ rbdPosts :: [RequestPart]
, rbdResponse :: (Maybe SResponse)
}
-- | Request parts let us discern regular key/values from files sent in the request. -- | Request parts let us discern regular key/values from files sent in the request.
data RequestPart data RequestPart
@ -122,29 +155,11 @@ data RequestPart
-- response to analize the forms that the server is expecting to receive. -- response to analize the forms that the server is expecting to receive.
type RequestBuilder = ST.StateT RequestBuilderData IO type RequestBuilder = ST.StateT RequestBuilderData IO
-- | Both the OneSpec and RequestBuilder monads hold a response that can be analized,
-- by making them instances of this class we can have general methods that work on
-- the last received response.
class HoldsResponse a where
readResponse :: a -> Maybe SResponse
instance HoldsResponse (OneSpecData conn) where
readResponse (OneSpecData _ _ _ x) = x
instance HoldsResponse RequestBuilderData where
readResponse (RequestBuilderData _ x) = x
type Cookies = M.Map ByteString Cookie.SetCookie
-- | Start describing a Tests suite keeping cookies and a reference to the tested 'Application' -- | Start describing a Tests suite keeping cookies and a reference to the tested 'Application'
-- and 'ConnectionPool' -- and 'ConnectionPool'
ydescribe :: String -> YesodSpec site -> YesodSpec site ydescribe :: String -> YesodSpec site -> YesodSpec site
ydescribe label yspecs = tell [YesodSpecGroup label $ execWriter yspecs] ydescribe label yspecs = tell [YesodSpecGroup label $ execWriter yspecs]
type YesodSpec site = YesodSpecM site ()
type YesodSpecM site = Writer [YesodSpecTree site]
data YesodSpecTree site
= YesodSpecGroup String [YesodSpecTree site]
| YesodSpecItem String (YesodExample site ())
yesodSpec :: YesodDispatch site yesodSpec :: YesodDispatch site
=> site => site
-> YesodSpec site -> YesodSpec site
@ -155,7 +170,12 @@ yesodSpec site yspecs =
unYesod (YesodSpecGroup x y) = Core.SpecGroup x $ map unYesod y unYesod (YesodSpecGroup x y) = Core.SpecGroup x $ map unYesod y
unYesod (YesodSpecItem x y) = Core.it x $ do unYesod (YesodSpecItem x y) = Core.it x $ do
app <- toWaiAppPlain site app <- toWaiAppPlain site
ST.evalStateT y $ OneSpecData app site M.empty Nothing ST.evalStateT y YesodExampleData
{ yedApp = app
, yedSite = site
, yedCookies = M.empty
, yedResponse = Nothing
}
-- | Describe a single test that keeps cookies, and a reference to the last response. -- | Describe a single test that keeps cookies, and a reference to the last response.
yit :: String -> YesodExample site () -> YesodSpec site yit :: String -> YesodExample site () -> YesodSpec site
@ -163,28 +183,43 @@ yit label example = tell [YesodSpecItem label example]
-- Performs a given action using the last response. Use this to create -- Performs a given action using the last response. Use this to create
-- response-level assertions -- response-level assertions
withResponse :: HoldsResponse a => (SResponse -> ST.StateT a IO b) -> ST.StateT a IO b withResponse' :: MonadIO m
withResponse f = maybe err f =<< fmap readResponse ST.get => (state -> Maybe SResponse)
-> (SResponse -> ST.StateT state m a)
-> ST.StateT state m a
withResponse' getter f = maybe err f . getter =<< ST.get
where err = failure "There was no response, you should make a request" where err = failure "There was no response, you should make a request"
-- | Performs a given action using the last response. Use this to create
-- response-level assertions
withResponse :: (SResponse -> YesodExample site a) -> YesodExample site a
withResponse = withResponse' yedResponse
-- | Use HXT to parse a value from an html tag. -- | Use HXT to parse a value from an html tag.
-- Check for usage examples in this module's source. -- Check for usage examples in this module's source.
parseHTML :: HtmlLBS -> Cursor parseHTML :: HtmlLBS -> Cursor
parseHTML html = fromDocument $ HD.parseLBS html parseHTML html = fromDocument $ HD.parseLBS html
-- | Query the last response using css selectors, returns a list of matched fragments -- | Query the last response using css selectors, returns a list of matched fragments
htmlQuery :: HoldsResponse a => Query -> ST.StateT a IO [HtmlLBS] htmlQuery' :: MonadIO m
htmlQuery query = withResponse $ \ res -> => (state -> Maybe SResponse)
-> Query
-> ST.StateT state m [HtmlLBS]
htmlQuery' getter query = withResponse' getter $ \ res ->
case findBySelector (simpleBody res) query of case findBySelector (simpleBody res) query of
Left err -> failure $ query <> " did not parse: " <> T.pack (show err) Left err -> failure $ query <> " did not parse: " <> T.pack (show err)
Right matches -> return $ map (encodeUtf8 . TL.pack) matches Right matches -> return $ map (encodeUtf8 . TL.pack) matches
-- | Query the last response using css selectors, returns a list of matched fragments
htmlQuery :: Query -> YesodExample site [HtmlLBS]
htmlQuery = htmlQuery' yedResponse
-- | Asserts that the two given values are equal. -- | Asserts that the two given values are equal.
assertEqual :: (Eq a) => String -> a -> a -> YesodExample site () assertEqual :: (Eq a) => String -> a -> a -> YesodExample site ()
assertEqual msg a b = liftIO $ HUnit.assertBool msg (a == b) assertEqual msg a b = liftIO $ HUnit.assertBool msg (a == b)
-- | Assert the last response status is as expected. -- | Assert the last response status is as expected.
statusIs :: HoldsResponse a => Int -> ST.StateT a IO () statusIs :: Int -> YesodExample site ()
statusIs number = withResponse $ \ SResponse { simpleStatus = s } -> statusIs number = withResponse $ \ SResponse { simpleStatus = s } ->
liftIO $ flip HUnit.assertBool (H.statusCode s == number) $ concat liftIO $ flip HUnit.assertBool (H.statusCode s == number) $ concat
[ "Expected status was ", show number [ "Expected status was ", show number
@ -192,7 +227,7 @@ statusIs number = withResponse $ \ SResponse { simpleStatus = s } ->
] ]
-- | Assert the given header key/value pair was returned. -- | Assert the given header key/value pair was returned.
assertHeader :: HoldsResponse a => CI BS8.ByteString -> BS8.ByteString -> ST.StateT a IO () assertHeader :: CI BS8.ByteString -> BS8.ByteString -> YesodExample site ()
assertHeader header value = withResponse $ \ SResponse { simpleHeaders = h } -> assertHeader header value = withResponse $ \ SResponse { simpleHeaders = h } ->
case lookup header h of case lookup header h of
Nothing -> failure $ T.pack $ concat Nothing -> failure $ T.pack $ concat
@ -212,7 +247,7 @@ assertHeader header value = withResponse $ \ SResponse { simpleHeaders = h } ->
] ]
-- | Assert the given header was not included in the response. -- | Assert the given header was not included in the response.
assertNoHeader :: HoldsResponse a => CI BS8.ByteString -> ST.StateT a IO () assertNoHeader :: CI BS8.ByteString -> YesodExample site ()
assertNoHeader header = withResponse $ \ SResponse { simpleHeaders = h } -> assertNoHeader header = withResponse $ \ SResponse { simpleHeaders = h } ->
case lookup header h of case lookup header h of
Nothing -> return () Nothing -> return ()
@ -225,14 +260,14 @@ assertNoHeader header = withResponse $ \ SResponse { simpleHeaders = h } ->
-- | Assert the last response is exactly equal to the given text. This is -- | Assert the last response is exactly equal to the given text. This is
-- useful for testing API responses. -- useful for testing API responses.
bodyEquals :: HoldsResponse a => String -> ST.StateT a IO () bodyEquals :: String -> YesodExample site ()
bodyEquals text = withResponse $ \ res -> bodyEquals text = withResponse $ \ res ->
liftIO $ HUnit.assertBool ("Expected body to equal " ++ text) $ liftIO $ HUnit.assertBool ("Expected body to equal " ++ text) $
(simpleBody res) == BSL8.pack text (simpleBody res) == BSL8.pack text
-- | Assert the last response has the given text. The check is performed using the response -- | Assert the last response has the given text. The check is performed using the response
-- body in full text form. -- body in full text form.
bodyContains :: HoldsResponse a => String -> ST.StateT a IO () bodyContains :: String -> YesodExample site ()
bodyContains text = withResponse $ \ res -> bodyContains text = withResponse $ \ res ->
liftIO $ HUnit.assertBool ("Expected body to contain " ++ text) $ liftIO $ HUnit.assertBool ("Expected body to contain " ++ text) $
(simpleBody res) `contains` text (simpleBody res) `contains` text
@ -242,7 +277,7 @@ contains a b = DL.isInfixOf b (BSL8.unpack a)
-- | Queries the html using a css selector, and all matched elements must contain -- | Queries the html using a css selector, and all matched elements must contain
-- the given string. -- the given string.
htmlAllContain :: HoldsResponse a => Query -> String -> ST.StateT a IO () htmlAllContain :: Query -> String -> YesodExample site ()
htmlAllContain query search = do htmlAllContain query search = do
matches <- htmlQuery query matches <- htmlQuery query
case matches of case matches of
@ -254,7 +289,7 @@ htmlAllContain query search = do
-- element contains the given string. -- element contains the given string.
-- --
-- Since 0.3.5 -- Since 0.3.5
htmlAnyContain :: HoldsResponse a => Query -> String -> ST.StateT a IO () htmlAnyContain :: Query -> String -> YesodExample site ()
htmlAnyContain query search = do htmlAnyContain query search = do
matches <- htmlQuery query matches <- htmlQuery query
case matches of case matches of
@ -264,19 +299,19 @@ htmlAnyContain query search = do
-- | Performs a css query on the last response and asserts the matched elements -- | Performs a css query on the last response and asserts the matched elements
-- are as many as expected. -- are as many as expected.
htmlCount :: HoldsResponse a => Query -> Int -> ST.StateT a IO () htmlCount :: Query -> Int -> YesodExample site ()
htmlCount query count = do htmlCount query count = do
matches <- fmap DL.length $ htmlQuery query matches <- fmap DL.length $ htmlQuery query
liftIO $ flip HUnit.assertBool (matches == count) liftIO $ flip HUnit.assertBool (matches == count)
("Expected "++(show count)++" elements to match "++T.unpack query++", found "++(show matches)) ("Expected "++(show count)++" elements to match "++T.unpack query++", found "++(show matches))
-- | Outputs the last response body to stderr (So it doesn't get captured by HSpec) -- | Outputs the last response body to stderr (So it doesn't get captured by HSpec)
printBody :: HoldsResponse a => ST.StateT a IO () printBody :: YesodExample site ()
printBody = withResponse $ \ SResponse { simpleBody = b } -> printBody = withResponse $ \ SResponse { simpleBody = b } ->
liftIO $ hPutStrLn stderr $ BSL8.unpack b liftIO $ hPutStrLn stderr $ BSL8.unpack b
-- | Performs a CSS query and print the matches to stderr. -- | Performs a CSS query and print the matches to stderr.
printMatches :: HoldsResponse a => Query -> ST.StateT a IO () printMatches :: Query -> YesodExample site ()
printMatches query = do printMatches query = do
matches <- htmlQuery query matches <- htmlQuery query
liftIO $ hPutStrLn stderr $ show matches liftIO $ hPutStrLn stderr $ show matches
@ -298,7 +333,12 @@ fileByName name path mimetype = do
-- This looks up the name of a field based on the contents of the label pointing to it. -- This looks up the name of a field based on the contents of the label pointing to it.
nameFromLabel :: T.Text -> RequestBuilder T.Text nameFromLabel :: T.Text -> RequestBuilder T.Text
nameFromLabel label = withResponse $ \ res -> do nameFromLabel label = do
mres <- fmap rbdResponse ST.get
res <-
case mres of
Nothing -> failure "nameFromLabel: No response available"
Just res -> return res
let let
body = simpleBody res body = simpleBody res
mfor = parseHTML body mfor = parseHTML body
@ -345,7 +385,7 @@ fileByLabel label path mime = do
-- Receives a CSS selector that should resolve to the form element containing the nonce. -- Receives a CSS selector that should resolve to the form element containing the nonce.
addNonce_ :: Query -> RequestBuilder () addNonce_ :: Query -> RequestBuilder ()
addNonce_ scope = do addNonce_ scope = do
matches <- htmlQuery $ scope `mappend` "input[name=_token][type=hidden][value]" matches <- htmlQuery' rbdResponse $ scope <> "input[name=_token][type=hidden][value]"
case matches of case matches of
[] -> failure $ "No nonce found in the current page" [] -> failure $ "No nonce found in the current page"
element:[] -> byName "_token" $ head $ attribute "value" $ parseHTML element element:[] -> byName "_token" $ head $ attribute "value" $ parseHTML element
@ -380,22 +420,26 @@ doRequest method url paramsBuild = doRequestHeaders method url [] paramsBuild
-- headers as well as letting you specify the request method. -- headers as well as letting you specify the request method.
doRequestHeaders :: H.Method -> BS8.ByteString -> [H.Header] -> RequestBuilder a -> YesodExample site () doRequestHeaders :: H.Method -> BS8.ByteString -> [H.Header] -> RequestBuilder a -> YesodExample site ()
doRequestHeaders method url extrahead paramsBuild = do doRequestHeaders method url extrahead paramsBuild = do
OneSpecData app conn oldCookies mRes <- ST.get YesodExampleData app conn oldCookies mRes <- ST.get
-- expire cookies and filter them for the current path. TODO: support max age -- expire cookies and filter them for the current path. TODO: support max age
currentUtc <- liftIO getCurrentTime currentUtc <- liftIO getCurrentTime
let cookies = M.filter (checkCookieTime currentUtc) oldCookies let cookies = M.filter (checkCookieTime currentUtc) oldCookies
cookiesForPath = M.filter checkCookiePath cookies cookiesForPath = M.filter checkCookiePath cookies
RequestBuilderData parts _ <- liftIO $ ST.execStateT paramsBuild $ RequestBuilderData [] mRes RequestBuilderData parts _ <- liftIO $ ST.execStateT paramsBuild RequestBuilderData
let req = if DL.any isFile parts { rbdPosts = []
then makeMultipart cookiesForPath parts , rbdResponse = mRes
else makeSinglepart cookiesForPath parts }
let maker
| DL.any isFile parts = makeMultipart
| otherwise = makeSinglepart
req = maker cookiesForPath parts
response <- liftIO $ runSession (srequest req) app response <- liftIO $ runSession (srequest req) app
let newCookies = map (Cookie.parseSetCookie . snd) $ DL.filter (("Set-Cookie"==) . fst) $ simpleHeaders response let newCookies = map (Cookie.parseSetCookie . snd) $ DL.filter (("Set-Cookie"==) . fst) $ simpleHeaders response
cookies' = M.fromList [(Cookie.setCookieName c, c) | c <- newCookies] `M.union` cookies cookies' = M.fromList [(Cookie.setCookieName c, c) | c <- newCookies] `M.union` cookies
ST.put $ OneSpecData app conn cookies' (Just response) ST.put $ YesodExampleData app conn cookies' (Just response)
where where
isFile (ReqFilePart _ _ _ _) = True isFile (ReqFilePart _ _ _ _) = True
isFile _ = False isFile _ = False