Unit Testing User Interactions
To round out our month of IO, I'd like to bring together several of the topics I've mentioned over the course of the month. A few weeks ago when talking about the interact function, I brought up the example of a command line program that would allow the user to enter simple addition expressions and print out the answer. Then going back to the first article this month, I mentioned how we can use the Handle
abstraction to write a program that could work with either terminal input or file input so that we can test it. And finally, we can go all the way back to Monads month for some information on lifting functions and creating our own monad.
Today we're going to combine all these ideas! We'll have a simple command line program that will use a custom monad to abstract away input details, and then write some tests for it!
Let's write this program in a test-driven way. What are the use cases we want? Well each time a user enters a line on the terminal, we'll treat that as an expression to evaluate, and then print the solution.
-- Input
4 + 5
6 + -2
-- Output
9
4
If they enter something that doesn't follow our simple equation format, it should print an appropriate message:
-- Input
4 + 5 + 6
3 +
Hello + Goodbye
4 * 5
-- Output
There are too many parts! Please enter something in the format "x + y"
There are too few parts! Please enter something in the format "x + y"
It doesn't look like those are numbers!
Please only use addition!
And last of all, the program should be able to "recover". So if the user has one incorrect line, they can still enter in another equation and it should work.
-- Input
6 +
9 + 14
-- Output
There are too few parts! Please enter something in the format "x + y"
23
So how will we write this program in a way that we can test it? The key idea is that we'll create a monad that stores the "Handles" we're working with, and then we'll be able to customize it. So let's create a monad type that has a Reader
over our input and output handles.
data AppConfig = AppConfig
{ inHandle :: Handle
, outHandle :: Handle
}
newtype AppMonad a = AppMonad (ReaderT AppConfig IO a)
deriving (Functor, Applicative, Monad)
We can start with some simple instances for MonadIO
and MonadReader
, as well as a "run" function.
instance MonadIO AppMonad where
liftIO = AppMonad . lift
instance MonadReader AppConfig AppMonad where
ask = AppMonad ask
local f (AppMonad a) = AppMonad (local f a)
runApp :: AppMonad a -> (Handle, Handle) -> IO a
runApp (AppMonad action) (inH, outH) = runReaderT action (AppConfig inH outH)
Now we can write some functions that will read and write using our handles.
appGetLine :: AppMonad String
appGetLine = do
inH <- asks inHandle
liftIO $ hGetLine inH
appPutStrLn :: String -> AppMonad ()
appPutStrLn output = do
outH <- asks outHandle
liftIO $ hPutStrLn outH output
appIsEOF :: AppMonad Bool
appIsEOF = do
inH <- asks inHandle
liftIO $ hIsEOF inH
Now let's write the core logic function for our program, taking the line of input and producing a line of output:
evalLine :: String -> String
evalLine input = case splitOn " " input of
[first, op, second] -> if op /= "+"
then "Please only use addition!"
else case (readMaybe first, readMaybe second) of
(Just x, Just y) -> show (x + y)
_ -> "It doesn't look like those are numbers!"
(first : op : second : other : _) -> "There are too many parts! Please enter something in the format \"x + y\""
_ -> "There are too few parts! Please enter something in the format \"x + y\""
And now it's straightforward to write the input/output loop:
runCLI :: AppMonad ()
runCLI = go
where
go = do
ended <- appIsEOF
if ended
then return ()
else do
input <- appGetLine
let output = evalLine input
appPutStrLn output
go
Finally, in the "main" function, we just need to call runApp
with the standard handles:
main :: IO ()
main = runApp runCLI (stdin, stdout)
In our testing code, then we can write a function that will take two file paths, a file containing our expected input, and a file containing our expected output. It will create an input handle from the first first, and create a temporary file (remember that concept?) for our program's output handle.
testCLIProgram :: FilePath -> FilePath -> Assertion
testCLIProgram inputFile expectedOutputFile = do
currentDir <- getCurrentDirectory
inH <- openFile inputFile ReadMode
(actualOutputFile, outH) <- openTempFile currentDir "output.txt"
...
Then we'll run our program, which will write all its output to the temporary file. Then we'll reset the output handle to the beginning (remember it's still readable), and compare its contents to those in the expected output. If they match, our program works!
testCLIProgram :: FilePath -> FilePath -> Assertion
testCLIProgram inputFile expectedOutputFile = do
currentDir <- getCurrentDirectory
(actualOutputFile, outH) <- openTempFile currentDir "output.txt"
inH <- openFile inputFile ReadMode
runApp runCLI (inH, outH)
hSeek outH AbsoluteSeek 0
actualOutput <- hGetContents outH
expectedOutput <- readFile expectedOutputFile
actualOutput @?= expectedOutput
hClose inH
hClose outH
removeFile actualOutputFile
This lists all our operations in logical order, but it still doesn't necessarily cover all the exceptional cases correctly! We might still want to use the bracket
pattern to ensure file cleanup happens correctly. The "resources" we acquire are the temporary file and its handle, the input handle, and the expected output string. We want to close the handles and delete the file once everything is finished running:
testCLIProgram :: FilePath -> FilePath -> Assertion
testCLIProgram inputFile expectedOutputFile = bracket acquire release runTest
where
acquire :: IO (FilePath, Handle, Handle, String)
acquire = do
currentDir <- getCurrentDirectory
(actualOutputFile, outH) <- openTempFile currentDir "actual_output.txt"
inH <- openFile inputFile ReadMode
expectedOutput <- readFile expectedOutputFile
return (actualOutputFile, outH, inH, expectedOutput)
release :: (FilePath, Handle, Handle, String) -> IO ()
release (fp, outH, inH, _) = do
hClose outH
hClose inH
removeFile fp
runTest :: (FilePath, Handle, Handle, String) -> IO ()
runTest (fp, outH, inH, expectedOutput) = do
runApp runCLI (inH, outH)
hSeek outH AbsoluteSeek 0
actualOutput <- hGetContents outH
actualOutput @?= expectedOutput
And so our "test main" can now just run the different tests as it needs to!
main :: IO ()
main = defaultMain $ testGroup
[ testCase "App 1" (testCLIProgram "input1.txt" "output1.txt")
, testCase "App 2" (testCLIProgram "input2.txt" "output2.txt")
, testCase "App 3" (testCLIProgram "input3.txt" "output3.txt")
]
So in the course of this article, I think we managed to use at least half a dozen of our monad and IO concepts! So hopefully you are beginning to see how all these ideas build on each other and allow you to do some pretty cool things!
Next month, we'll kind of be sticking with the IO theme. But we'll start looking specifically at exceptional cases and the different ways we have to handle those more smoothly. If you want to stay up to date with all the latest topics we're covering at Monday Morning Haskell, make sure you subscribe to our monthly newsletter! If you miss a few articles over the course of the month, you'll always get a summary so you can catch up!