IO, IO, it's off to test we go

Posted on January 27, 2019
Tags: ,

Let’s say we have a function that uses the IO monad that we want to test. Now you may be aware that IO is the place in which all the bad things happen that aren’t supposed to happen in Haskell. Things like Database Connections, and Mutable Global Variables and (worst of all) Actual User Interactions. It doesn’t bear thinking about to be honest.

However, mock dramatics aside, IO is the part of our code where Side Effects happen, meaning stuff is a lot more difficult to write tests for, given our functions may not always return the things we expect them to. Last time, we spoke about polymorphism - and this seems like a nice opportunity to expand on that and use our new polymorphic powers to make some code easier to test.

Contrived example

Here is some code that uses getCurrentTime from the Data.Time.Clock library to fetch the current system time and then work out whether it is time to stop working and eat some lunch.

import qualified Data.Time.Clock     as Clock
import qualified Data.Time.Calendar  as Cal
import qualified Data.Time.LocalTime as Time

getHour :: Clock.UTCTime -> Int
getHour = Time.todHour
        . Time.timeToTimeOfDay
        . Clock.utctDayTime

isItLunchTime :: IO Bool
isItLunchTime
  = lunchCheck <$> getHour <$> Clock.getCurrentTime
    where
      lunchCheck hr = hr >= 12 && hr <= 14

The getHour function is a simple pure function helper - mainly we’ll be looking at isItLunchTime. Let’s look at the type signature again:

isItLunchTime :: IO Bool

This function has no inputs, and the IO Bool return type means that it will return True or False, wrapped in the IO type to remind us that evil has been done and the gods of referential transparency have not been appeased.

If you are thinking something like “can we not just extract the pure functions, test those, and not bother about the rest?”, then Don’t Read On because although that’s Not A Terrible Idea we’re absolutely Not Doing That.

Injection-based solution

In other languages, the solution to making stuff more testable is to use Dependency Injection to pass in any functions that use side effects, allowing mock versions to be passed in when testing.

This version of the function lets us pass in a time fetching function (such as getCurrentTime) instead.

injectableLunch :: IO Clock.UTCTime -> IO Bool
injectableLunch getCurrentTime
  = lunchCheck <$> getHour <$> getCurrentTime
    where
      lunchCheck hr = hr >= 12 && hr <= 14

Now, instead of calling isItLunchTime, we’d call injectableLunch Clock.getCurrentTime. It’s still not much more testable though because of that IO though. Does it need to be IO though? What if we generalise the type signature though? Instead of IO, let’s just use any old Monad and see if that works.

testableLunch :: (Monad m) => m Clock.UTCTime -> m Bool
testableLunch getCurrentTime
  = lunchCheck <$> getHour <$> getCurrentTime
    where
      lunchCheck hr = hr >= 12 && hr <= 14

Because we’re not actually running any functions that require the IO monad (ie, readFile, putStrLn) any monad is totally fine for this function. So long as the Monad m of our passed in time-getting function matches the Monad m of the return value, then this function is having a Great Time.

As a slight detour, let’s make some fake UTCTime values for testing:

baseTestTime :: Clock.UTCTime
baseTestTime
  = Clock.UTCTime
      { Clock.utctDay     = Cal.ModifiedJulianDay 12000
      , Clock.utctDayTime = 0
      }

lunchTestTime :: Clock.UTCTime
lunchTestTime
  = baseTestTime { Clock.utctDayTime = 44000 }

Excellent. What a nice couple of examples. For more information about what these do, check out the time library.

Now we have some test values, we can then make functions to test our testableLunch function with a safer Monad like Identity. Identity is the Do Nothing monad, it has no special characteristics and does nothing for any particular interest.

testNotLunch :: Identity Bool
testNotLunch = testableLunch (pure baseTestTime)
-- Identity False
testIsLunch :: Identity Bool
testIsLunch = testableLunch (pure lunchTestTime)
-- Identity True

(What’s pure? It’s just a function that wraps a value inside the default Monad context, so in this case, turns "dog" into Identity "dog" etc)

Then when we want to export the function for use with IO, we can export this:

isItLunchTime3 :: IO Bool
isItLunchTime3 = testableLunch Clock.getCurrentTime

Seems pretty OK. What about a more idiomatic Haskell approach using our pal typeclasses?

Testing IO with a typeclass

Another way to do this is capture the idea of “Monad That Knows What The Time Is” using a typeclass.

Let’s call ours MonadTime, because we lack imagination:

class Monad m => MonadTime m where
  getTheTimePlease :: m Clock.UTCTime

Note the constraint - we can only make instances of MonadTime for things that are already an instance of Monad. By writing instances of MonadTime for IO and Identity, we can tell Haskell what to do when it’s asked for the time.

instance MonadTime Identity where
  getTheTimePlease = pure lunchTestTime

instance MonadTime IO where
  getTheTimePlease = Clock.getCurrentTime

Identity will return a static value, and IO will use the actual function.

Here’s our new version of the lunch function. Notice we’ve changed our constraint from Monad m

classyLunch :: (MonadTime m) => m Bool
classyLunch
  = lunchCheck <$> getHour <$> getTheTimePlease
    where
      lunchCheck hr = hr >= 12 && hr <= 14

Now, depending on the context in which we use classyLunch, it’ll do different things.

testClassyLunch :: Identity Bool
testClassyLunch = classyLunch

ioClassyLunch :: IO Bool
ioClassyLunch = classyLunch

Hooray! Our contrived function is now testable and also ready for production. Great stuff.

This concept of different typeclasses for Monads is a key part of what is called mtl style of code, which we will come to once we’ve looked at Monad Transformers.

Further reading:

Time library

Better IO testing through Monads