Hedgehog on a REST API, part 3
In my previous post on using Hedgehog on a REST API, Hedgehog on a REST API, part 2 I ran the test a few times and adjusted the model to deal with the incorrect assumptions I had initially made. In particular, I had to adjust how I modelled the User ID. Because of the simplicity of the API that wasn't too difficult. However, that kind of completely predictable ID isn't found in all APIs. In fact, it's not uncommon to have completely random IDs in API (often they are UUIDs).
So, I set out to try to deal with that. I'm still using the simple API from the previous posts, but this time I'm pretending that I can't build the ID into the model myself, or, put another way, I'm capturing the ID from the responses.
The model state
When capturing the ID it's no longer possible to use a simple Map Int Text
for
the state, because I don't actually have the ID until I have an HTTP response.
However, the ID is playing an important role in the constructing of a sequence
of actions. The trick is to use Var Int v
instead of an ordinary Int
. As I
understand it, and I believe that's a good enough understanding to make use of
Hedgehog possible, is that this way the ID is an opaque blob in the construction
phase, and it's turned into a concrete value during execution. When in the
opaque state it implements enough type classes to be useful for my purposes.
newtype State (v :: * -> *)= State (M.Map (Var Int v) Text) deriving (Eq, Show)
The API calls: add user
When taking a closer look at the Callback
type not all the callbacks will get
the state in the same form, opaque or concrete, and one of them, Update
actually receives the state in both states depending on the phase of execution.
This has the most impact on the add user action. To deal with it there's a need
to rearrange the code a bit, to be specific, commandExecute
can no longer
return a tuple of both the ID and the status of the HTTP response because the
update function can't reach into the tuple, which it needs to update the state.
That means the commandExecute
function will have to do tests too. It is nice
to keep all tests in the callbacks, but by sticking a MonadTest m
constraint
on the commandExecute
it turns into a nice solution anyway.
addUser :: (MonadGen n, MonadIO m, MonadTest m) => Command n m State addUser = Command gen exec [ Update u ] where gen _ = Just $ AddUser <$> Gen.text (Range.linear 0 42) Gen.alpha exec (AddUser n) = do (s, ui) <- liftIO $ do mgr <- newManager defaultManagerSettings addReq <- parseRequest "POST http://localhost:3000/users" let addReq' = addReq { requestBody = RequestBodyLBS (encode $ User 0 n)} addResp <- httpLbs addReq' mgr let user = decode (responseBody addResp) :: Maybe User return (responseStatus addResp, user) status201 === s assert $ isJust ui (userName <$> ui) === Just n return $ userId $ fromJust ui u (State m) (AddUser n) o = State (M.insert o n m)
I found that once I'd come around to folding the Ensure
callback into the
commandExecute
function the rest fell out from the types.
The API calls: delete user
The other actions, deleting a user and getting a user, required only minor changes and the changes were rather similar in both cases.
Not the type for the action needs to take a Var Int v
instead of just a plain
Int
.
newtype DeleteUser (v :: * -> *) = DeleteUser (Var Int v) deriving (Eq, Show)
Which in turn affect the implementation of HTraversable
instance HTraversable DeleteUser where htraverse f (DeleteUser vi) = DeleteUser <$> htraverse f vi
Then the changes to the Command
mostly comprise use of concrete
in places
where the real ID is needed.
deleteUser :: (MonadGen n, MonadIO m) => Command n m State deleteUser = Command gen exec [ Update u , Require r , Ensure e ] where gen (State m) = case M.keys m of [] -> Nothing ks -> Just $ DeleteUser <$> Gen.element ks exec (DeleteUser vi) = liftIO $ do mgr <- newManager defaultManagerSettings delReq <- parseRequest $ "DELETE http://localhost:3000/users/" ++ show (concrete vi) delResp <- httpNoBody delReq mgr return $ responseStatus delResp u (State m) (DeleteUser i) _ = State $ M.delete i m r (State m) (DeleteUser i) = i `elem` M.keys m e _ _ (DeleteUser _) r = r === status200
Conclusion
This post concludes my playing around with state machines in Hedgehog for this time. I certainly hope I find the time to put it to use on some larger API soon. In particular I'd love to put it to use at work; I think it'd be an excellent addition to the integration tests we currently have.