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!

Previous
Previous

Throwing Exceptions: The Basics

Next
Next

Sizing Up our Files