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
= Time.todHour
getHour . Time.timeToTimeOfDay
. Clock.utctDayTime
isItLunchTime :: IO Bool
isItLunchTime= lunchCheck <$> getHour <$> Clock.getCurrentTime
where
= hr >= 12 && hr <= 14 lunchCheck hr
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
= hr >= 12 && hr <= 14 lunchCheck hr
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
= hr >= 12 && hr <= 14 lunchCheck hr
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
= Cal.ModifiedJulianDay 12000
{ Clock.utctDay = 0
, Clock.utctDayTime
}
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
= testableLunch (pure baseTestTime)
testNotLunch -- Identity False
testIsLunch :: Identity Bool
= testableLunch (pure lunchTestTime)
testIsLunch -- 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
= testableLunch Clock.getCurrentTime isItLunchTime3
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
= pure lunchTestTime
getTheTimePlease
instance MonadTime IO where
= Clock.getCurrentTime getTheTimePlease
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
= hr >= 12 && hr <= 14 lunchCheck hr
Now, depending on the context in which we use classyLunch
, it’ll do different things.
testClassyLunch :: Identity Bool
= classyLunch
testClassyLunch
ioClassyLunch :: IO Bool
= classyLunch ioClassyLunch
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: