How to capture IO?

I’ve come across a simple problem that I can’t solve nicely. I have a main :: IO () in one file, and I want to write a test suite that runs this main and inspects its standard output while it is running. The method to capture the standard output should be fairly portable (at least Linux and Mac, bonus points for Windows).

So far the only idea I had was to use turtle or similar, call cabal run externally and pipe it into a file or a further helper function. This feels super brittle, though, and involves stepping way to far out of the context.

There are some decade old, hacky packages like system-posix-redirect: A toy module to temporarily redirect a program's stdout. and io-capture: Capture IO actions' stdout and stderr, but they seem unmaintained, and the authors call their methods a hack anyways.

1 Like

Seems similar to this: How to implement a transcript program in Haskell? - #2 by tomjaguarpaw

If I came up against this problem I would look really hard for a way to rewrite main :: IO () into something yields its output into an iterator/pipe/conduit. Would that be possible in your case?

2 Likes

Unfortunately not. The situation is a similar one, I want to automatically test against a student’s solution to a programming task. It has to be simple and realistic, so I can’t use another type than IO.

The knob library looks useful, maybe I can just duplicate stdout to such a handle…

capture_ / hCapture_ from silently: Prevent or capture writing to stdout and other handles. seems to provide the functionality you are looking for.

Disclaimer: I can’t make any guarantees on it since I haven’t used it myself, but might be worth testing at least.

3 Likes

Thanks, I’ll test that! I was wondering already how test frameworks manage to suppress IO. This package seems to be part of hspec, so I have a good feeling about its maintenance state.

Although I’m wondering whether I can do IO at the same time! From the docs I have the feeling that if I do forkIO $ silence _, I can’t write to stdout in the foreground thread. Maybe I can still write to stderr

At least on Linux, silently seems to work for me! And in fact I can still write to stderr and capture the stdout generated by the given IO :slight_smile: The docs seem to claim that this works for Linux, Mac and Windows.

1 Like

Fundamentally, you want to redirect stdout and stderr to other file descriptors.

To do this, you can create another file descriptor, which will get a position like 3 (typically stdout is 1 and stderr is 2)
Then, you close stdout (1).
And dup (see man dup) your fd (3).
Dup guarantees the duplicate file descriptor takes the lowest available position, which is 1 (because we closed stdout)
Therefore, when you invoke someMain and it writes to stdout, it is really writing to file descriptor 1, which is your file :slight_smile:

import System.Posix.IO
import System.Posix.Files

main = do
  f <- createFile "mytestout" (foldr unionFileModes nullFileMode [ownerReadMode, ownerWriteMode])
  closeFd stdOutput
  dup f
  -- When someMain writes to stdout it is really writing to @f@
  someMain

someMain = do
  putStrLn "Writing to stdout!”

For a similar problem, but which also redirects the standard input, I am using the following code for testing a student assignment.

import Control.Applicative (liftA2)
import Control.Exception (bracket, finally)
import GHC.IO.Handle (hDuplicate, hDuplicateTo)
import System.IO (stdin, stdout, withFile, IOMode(ReadMode, WriteMode), hClose)
import System.IO.Temp (writeSystemTempFile, emptySystemTempFile)

redirect action inputFileName outputFileName = do
  withFile inputFileName ReadMode $ \hIn ->
    withFile outputFileName WriteMode $ \hOut ->
      bracket
        (liftA2 (,) (hDuplicate stdin) (hDuplicate stdout))
        (\(old_stdin, old_stdout) ->
           (hDuplicateTo old_stdin stdin >> hDuplicateTo old_stdout stdout)
           `finally`
           (hClose old_stdin >> hClose old_stdout))
        (\_ ->
           do
             hDuplicateTo hIn stdin
             hDuplicateTo hOut stdout
             action)

runWithInput action input = do
  inputFileName <- writeSystemTempFile "input.txt" input
  outputFileName <- emptySystemTempFile "output.txt"
  redirect action inputFileName outputFileName
  readFile outputFileName
module Main (main) where

import Test.Hspec
import Test.Hspec.QuickCheck
import Test.QuickCheck.Modifiers

import Run
import qualified StudentMainModule as T -- read a sequence of numbers and write its sum

main :: IO ()
main = hspec $ do
  describe "Executando o programa" $ do
    it "com quantidade negativa" $ do
      let xs = []
      out <- runWithInput T.main (unlines (map show (-10 : xs)))
      shouldContain out ("Soma dos números digitados: " ++ show (sum xs))
    prop "com quantidade não negativa" $ do
      \xs -> do
        out <- runWithInput T.main (unlines (map show (length xs : xs)))
        shouldContain out ("Soma dos números digitados: " ++ show (sum xs))
1 Like