REST API testing in Haskell with wreq and test-framework
Introduction
This blog post is about my experiences rewriting
Node.js/JavaScript-based REST unit tests in Haskell using wreq and
test-framework. I hope the test cases presented in this post are
useful practical examples of using test-framework
for IO-heavy
testing.
The tests discussed in this post are for my Hswtrack project. Hswtrack is a web application for exercise tracking. The application server is built with Snap and the UI in JavaScript with JQuery and Handlebars.js.
Here’s a summary of what type of REST entry points will be tested in this post:
Entry point | Verb | Description |
---|---|---|
/rest/login |
POST | Login with login and password parameters. |
/rest/new_user |
POST | Create a user with login and password parameters. |
/rest/app |
GET | Get logged in user login name and status. |
/rest/exercise |
GET | List existing exercises. |
/rest/exercise |
POST | Create a new exercise with name and type parameters. |
Most entry point require a prior successful login. The response body is encoded as JSON. When called unauthenticated, the server returns an HTTP 403 error code.
As the server talks to the client in JSON, I initially thought JavaScript + NodeJS would be a nice combo for developing these tests. I first tried Frisby which looked simple and had good looking documentation. Unfortunately it turned out to be cumbersome for the types of tests I wanted to write. I also tried writing my own test framework with Q promises but that came out like Tea party code too. (You can view the source for my JavaScript tests here.)
Feeling dissatisfied about the state of my tests, I decided to rewrite my unit tests in Haskell.
Types of tests
I wanted to develop the following types of tests:
- Test that user creation works
- Test that login works
- Test that REST entry points deny access if not logged in
- Test object creation, update and deletion when logged in
- Test that attempts to modify or delete other users’ data are rejected
Any tests that modify or create objects need to run with an authenticated user. This means each such test would need to either log in as part of its init sequence or have its authentication cookies passed into it. I went for the latter approach as it allows logging in once and running multiple tests with the same cookies.
I define top-level test cases called createUserTests
and
loginUserTests
which first perform user creation or login, followed
by running a list of sub-tests with cookies acquired from the login
process. The sequencing is explicitly declared by using
test-framework
’s TestGroup
s and Test
s.
Here’s the definition of loginUserTests
:
-- | Login a user and run a list of subtests with cookies acquired
-- from the login process.
loginUserTests :: [(String, Options -> Assertion)] -> IO Test
= do
loginUserTests tests <- post (mkUrl "/rest/login") ["login" := login, "password" := passwd]
r let opts = defaults & cookies .~ (r ^. responseCookieJar)
return
. testGroup "Tests with logged in user"
. map (\(name, test) -> testCase name (test opts)) $ tests
It performs a HTTP POST on /rest/login
with the test user’s login
credentials, grabs the authentication cookies with defaults & cookies .~ (r ^. responseCookieJar)
and passes them to sub-tests so that they
can run authenticated.
Here’s how loginUserTests
gets used:
-- Test case that tests that we're successfully logged in
-- Options must contain the necessary login cookies.
testLoggedInOk :: Options -> Assertion
= do
testLoggedInOk opts <- getWith opts (mkUrl "/rest/app")
r Just True @=? (r ^? responseBody . key "loggedIn" . _Bool)
main :: IO ()
=
main
defaultMain$ createUserTests [("logged in?", testLoggedInOk)]
[ buildTest $ loginUserTests [("logged in?", testLoggedInOk)]
, buildTest ]
Function testLoggedInOk
tests that it can HTTP GET /rest/app
successfully. It also tests that the returned JSON object contains a
field loggedIn
with value true
.
Negative testing
One shouldn’t forget about negative testing, so let’s develop a test for checking that entry points correctly respond with an error 403 on unauthenticated access:
-- GET requests against 'url' and expect to get error 403 back
testLoggedInFail :: String -> Options -> Assertion
= do
testLoggedInFail url opts >>= check
E.try (getWith opts url) where
Left (HT.StatusCodeException s _ _))
check (| s ^. statusCode == 403 = assertBool "error ok" True
| otherwise = assertBool "unexpected status code" False
Left _) = assertBool "unexpected exception caught" False
check (Right r) = assertBool "req should've failed" False
check (
main :: IO ()
=
main "Require auth fail" requireAuthFail]
defaultMain [testGroup where
=
requireAuthFail map (\u -> testCase u (testLoggedInFail (mkUrl u) defaults)) authReqd
-- REST entry points which require user to be logged in
= [ "/rest/app"
authReqd "/rest/weights"
, "/rest/notes"
, "/rest/exercise"
, "/rest/workout/exercise"
, "/rest/workout"
, "/rest/workout"
, "/rest/stats/workout"
, ]
Creating objects
Here we will test two entry points: one for creating a new exercise
like “Chin-ups” and one for listing existing exercises. Creation is a
HTTP POST to /rest/exercise
and listing exercises is HTTP GET
/rest/exercise
.
A successful POST to /rest/exercise
will create a new object on the
server and return the object to the client as JSON. Here’s what that
repsonse might look like:
{ "id":2, "name": "Chin-ups", "type":"BW" }
A successful GET of /rest/exercise
will retrieve the full list of
available exercises. It looks something like this:
[
{ "id":1, "name": "Push-ups", "type":"BW" },
{ "id":2, "name": "Chin-ups", "type":"BW" }
]
To test creating exercise objects, we’ll create an exercise, verify that its returned properties match what we gave on creation, and finally retrieve the complete exercise list and check that the object is listed.
Here’s the code for the above test strategy. The Aeson Lens API is particularly handy for this type of ad hoc JSON value inspection!
testAddExercise :: Options -> Assertion
= do
testAddExercise opts let name = "Chin-ups" :: T.Text
= "BW" :: T.Text
ty <- postWith opts (mkUrl "/rest/exercise") ["name" := name, "type" := ty]
r -- Verify that the newly created object matches creation params
@=? r ^. responseBody . key "name" . _String
name @=? r ^. responseBody . key "type" . _String
ty -- Verify that the object ended up in the global list of exercises
let (Just oid) = r ^? responseBody . key "id" . _Integer
<- getWith opts (mkUrl "/rest/exercise")
r "oid should be in list" (oid `elem` exercises r)
assertBool where
= r ^.. responseBody . values . key "id" . _Integer exercises r
Conclusion
I only just got started developing these tests and so coverage is still poor. I’m pretty sure that as I develop new tests, interesting ways to restructure and generalize these tests will emerge.
You can find the full source code to these tests here.
Thanks for reading!