There are more articles out there discussing this topic but this is my take on it. This topic can be long and complicated but I’ll try and extract a section of it that we can focus on.
Let’s do a contrived example. Let’s say we have a function that reads a file, processes it by capitalizing all the characters, then writes the results to another file. This is the first iteration of this function.
{-# LANGUAGE OverloadedStrings #-}
module Lib where
import Relude
-- text
import qualified Data.Text as T
-- 1. reads the file
-- 2. capitalizes all the characters
-- 3. writes result to the output path
processFile :: FilePath -> FilePath -> IO ()
= do
processFile fp output <- readFileText fp
inputFile writeFileText output ( T.toUpper inputFile )
This adequately performs the task we want. The problem comes when we want to test the text processing without interacting with the file system. Sure, we can unit test the text processing it self but what if we want to see how the text processing behaves in the processFile
function?
I learned mtl, tagless final encoding, and started reading about free monads, fused-effects, etc to be able to extract IO
out; among other things. Then, I totally ignored an easier technique. Which is to make the “side-effecty” functions into a parameter, and give it a basic type constraint of Monad
instead of IO
, like this
processFileBase :: Monad m
=> ( FilePath -> m Text )
-> ( FilePath -> Text -> m () )
-> FilePath
-> FilePath
-> m ()
= do
processFileBase readFileF writeFileF inputPath outputPath <- readFileF inputPath
inputFile writeFileF outputPath ( T.toUpper inputFile )
We can use some type alias to make it less confusing.
type ReadFileF m = FilePath -> m Text
type WriteFileF m = FilePath -> Text -> m ()
processFileBase :: Monad m
=> ReadFileF m
-> WriteFileF m
-> FilePath
-> FilePath
-> m ()
We’ve extracted out the IO
. Yaaay! This function is now more flexible. We can pass in functions as long as they have a Monad
instance. If we go back to our IO
implementation we can provide it with functions from relude
.
processFileWithIO :: MonadIO m => FilePath -> FilePath -> m ()
= processFileBase
processFileWithIO inputPath outputPath
readFileText
writeFileText
inputPath outputPath
Another advantage of this technique is we can use another library and don’t have to restructure our program. Let’s say we ended up dropping relude
and using
prelude
instead. We can massage the writeFile
and readFile
functions from prelude
so it can fit our program. Like so
readFilePrelude :: MonadIO m => FilePath -> m Text
= liftIO . fmap T.pack <$> Prelude.readFile
readFilePrelude
writeFilePrelude :: MonadIO m => FilePath -> Text -> m ()
= liftIO
writeFilePrelude fp content $ Prelude.writeFile fp ( T.unpack content )
processFileWithIO :: MonadIO m => FilePath -> FilePath -> m ()
= processFileBase
processFileWithIO inputPath outputPath
readFilePrelude
writeFilePrelude
inputPath outputPath
In testing, we can provide it different functions.
{-# LANGUAGE OverloadedStrings #-}
module ProcessFileSpec where
import qualified Data.HashMap.Strict as HS
import Lib
import Relude
import Test.Hspec
textFileToProcess :: Text
=
textFileToProcess "Letting the cat out of the bag is a whole lot easier than putting it back in."
spec :: Spec
= do
spec "processFile" $ do
describe "will process the file and capitalize every character" $ do
it <- newIORef HS.empty
ioRef let
= "sample-output.txt"
outPath
testReadFile :: Monad m => FilePath -> m Text
= pure textFileToProcess
testReadFile _
testWriteFile :: MonadIO m => FilePath -> Text -> m ()
= liftIO $
testWriteFile outputPath content -> HS.insert outputPath content ref )
modifyIORef ioRef (\ref
"input-path.txt" outPath
processFileBase testReadFile testWriteFile
<- readIORef ioRef
result
shouldBe result$ HS.singleton outPath
"LETTING THE CAT OUT OF THE BAG IS A WHOLE LOT EASIER THAN PUTTING IT BACK IN."
Here we’re using IORef
but you can use Map
, List
, StateT
, WriterT
. It’s totally up to you. Whatever suits your use-case.
This technique can take you a long way! It is also a good complement to techniques like mtl
and tagless final encoding