One simple way to do this is to use a Pipe to abstract out reads and writes to handles. One type you can use is:

example :: Monad m => Pipe String String m ()

For example, let's say that your original code looked something like this:

original :: IO () original = do str1 <- getLine str2 <- getLine putStrLn (str1 ++ str2)

The new pipes version would look like this:

import Pipes example :: Monad m => Pipe String String m () example = do str1 <- await str2 <- await yield (str1 ++ str2)

Then, you can test it purely like this:

>>> import qualified Pipes.Prelude as Pipes >>> Pipes.toList (each ["Hello, ", "world!"] >-> example) ["Hello, world!"]

... or you can test it with real input and output:

>>> runEffect $ Pipes.stdinLn >-> example >-> Pipes.stdoutLn Hello, <Enter> world!<Enter> Hello, world!

This lets you keep your main logic pure, and then choose whether or not to run it purely or impurely.