Unit testing IO in Haskell
The generally side-effect free nature of Haskell code makes it convenient to test. Haskell programs can interact with the outside world – otherwise they would be useless – but these side-effects are only possible in the IO monad. It is still important to test this code that performs IO, but we found it much more challenging. This post explains how we used mock IO monads along with monad typeclasses to solve this problem, while also improving our application code in the process.
Note that this post assumes intermediate knowledge of Haskell
Recently I began working on a Haskell Pusher HTTP library. This is a good example of the structure of many programs: there is a single location where an IO action is performed (in the case of this library making an HTTP request), and the rest of the code is pure logic that generates the arguments for the IO action, and processes the result. We will use the IO in this library as an example of something we want to test.
The Haskell Pusher library uses the following function to make HTTP requests. It is defined in the http-client library and returns an IO action to make a request and return the response as a lazy ByteString.
httpLbs :: Request -> Manager -> IO (Response ByteString)
Mocking the server
In order to test the IO code, we need to simulate the server that the
library is interacting with, using pure code. Because we need to
represent something that responds with data, we can model this using a
reader
monad.
ask
ing for data can simulate making requests to a server.
newtype MockServer m a = MockServer
{ server :: ReaderT (Response BL.ByteString) m a }
deriving (Applicative, Functor, Monad, MonadTrans)
We defined MockServer
as a monad transformer so that it can be
combined with other monads.
The idea is that we can “run” this reader monad with the particular response we want to send.
runMockServer
:: MockServer m a
-> Response BL.ByteString
-> m a
runMockServer (MockServer s) = runReaderT s
Generalising the types
We now have a problem because the Pusher library code needs to type
check with this mock server, as well as a real server (IO
). The
existing concrete type of IO a
won’t do. The solution in many
languages would be dependency injection: take the httpLbs
function as
a parameter. But the solution we came up with does not require the
function to be explicitly passed in; instead we create a generic monad
typeclass, that can represent both the mock and IO
, to be used as the
return type. This allows the caller of the library to pick the concrete
type that the function should return. Lets’s see this in practice:
class Monad m => MonadHTTP m where
httpLbs :: Request -> Manager -> m (Response BL.ByteString)
-- And the two instances we need:
instance MonadHTTP IO where
-- The regular function from the http-client library
httpLbs = HTTP.Client.httpLbs
instance Monad m => MonadHTTP (MockServer m) where
-- ask gets the environment from the reader monad;
-- i.e. what you "run" it with
httpLbs _ _ = ask
Using these typeclasses has a benefit beyond testing:
- It splits the IO type up into types that specifically reflects the
action to be performed: for example
MonadHTTP
could be just for making HTTP requests, andMonadTime
could be used for getting the current time - It is easier to refactor because new concrete implementations can be swapped in by implementing the typeclass. For example, if you no longer wanted to perform an IO action, then you could simply make your pure code an instance of the monad typeclass instead.
Using the mock server in tests
Let’s see how we test the
get
function for performing HTTP GET requests in the Haskell Pusher library
using the mock server.
First define the response the mock server will respond with
succeededResponse :: Response BL.ByteString
succeededResponse = Response
{ responseStatus = mkStatus 200 "success"
, responseVersion = http11
, responseHeaders = []
, responseBody = "{"data":"some body"}"
, responseCookieJar = createCookieJar []
, responseClose' = ResponseClose (return () :: IO ())
}
Then, using hspec, we can
compare this with the result of running our get
function (which uses
httpLbs
internally to make the request).
test :: Spec
test =
describe "HTTP.get" $ do
it "returns the body when the request is 200" $
withConnManager $ \mngr ->
runMockServer
(get mngr "http://example.com/path" [])
succeededResponse
`shouldBe`
(Right $
A.Object $
HM.singleton "data" (A.String "some body"))
And so we can test IO in much the same way as regular hspec tests.
Testing the similar post
function would be very similar, and testing a
failed response would involve defining a failedResponse
and running
the mock server with that instead.
When readers are not enough
In our example, the reader monad was enough to model the tests we wanted to implement; it was sufficient to hardcode a single response on a test-by-test basis. However, you may find that a real world system you are trying to mock requires more complex logic, for example, it may need to keep track of some state during the course of the test. In that case it may be more appropriate to use the more flexible state monad. In general, you will need to think carefully about what IO your system is performing, and exactly which monad(s) you will need in order to model it.
Pushing the boundaries of what can be mocked
The example we have shown in this post is a simple one. Things get much
more complicated in some real world applications, depending on the kind
of IO you wish to mock. For example, we have a much larger system that
we have applied a similar testing strategy to. We found the trickiest
kind of IO to mock was concurrency (specifically forkIO
) and
STM. It was possible, but it
did mean we had to write a mock thread scheduler – a non-trivial amount
of code.
An interesting future project would be to create a test harness that had built in support for mocking these common IO actions.
IO mocked! And other goodies
In general we found this approach to be a great way of testing IO in
Haskell. Not only this, but as mentioned before, generalising the return
types to typeclasses lead to other benefits. The caller now picks the
concrete return type, which makes refactoring easier, and also lets the
client of library code implement their own concrete return types.
Another advantage is that the type signatures are more descriptive than
just IO
. Finally, like with most testing, we found that it forced us
to structure our application code better, particularly in terms of
separating IO from the pure logic.
I am still relatively new to Haskell, so feedback is always welcome. In particular I have read how free monads can be used to achieve similar things, and I would be interested to hear how that approach compares.