Gabriella439 / pipes

Compositional pipelines
BSD 3-Clause "New" or "Revised" License
487 stars 72 forks source link

Question: Is it possible to unit test my Pipes with hspec? #178

Closed atcol closed 8 years ago

atcol commented 8 years ago

I originally asked this question about testing my code that uses Pipes with HSpec, and it's not conclusive how I might do so. I wondered if this is something you've considered for Pipes and whether it's possible at all?

For example, suppose I have this domain:

data Person = Person String Int | Unknown deriving (Show, Eq)
data Classification = Friend | Foe | Undecided deriving Show

and this Pipe:

classify :: Pipe Person (Person, Classification) IO ()
classify = do
    p@(Person name _) <- await
    case name of 
      "Alex" -> yield (p, Friend)
      "Bob" -> yield (p, Foe)
      _ -> yield (p, Undecided)

I'd like to be able to test it a bit like this:

alex = (Person "Alex" 31)
bob = (Person "Bob" 20)
no1 = (Person "ABC" 50)

spec = hspec $ do
  describe "classify" $ do
    it "determines friends" $ do
      -- e.g.  shouldYield :: Pipe a b IO () -> a -> b -> Expectation
      shouldYield classify alex (alex, Friend)
      shouldYield classify bob (bob, Foe)
    it "determines undecided" $ do
      shouldYield classify no1 (no1, Undecided)

Thanks!

michaelt commented 8 years ago

This is just a remark on the easiest case, but classify is a pure pipe as it stands. These can easily be tested via the the corresponding list function

pure_pipe_test :: Pipe a b Identity () -> [a] -> [b]
pure_pipe_test p ls = P.toList $ each ls >-> p

I haven't used hspec, but used similar relationships when writing quickcheck tests. So here I guess we can write:

shouldMatchPipe :: (Show a, Eq a) =>  Pipe a b Identity () -> [a] -> [b] -> Expectation
shouldMatchPipe p as bs = shouldMatchList  (pure_pipe_test p as) bs

That's probably not the best name, but anyway, then I can write

spec = hspec $ do
  describe "classify" $ do
    it "determines friends" $ do
      shouldMatchPipe classify [alex] [(alex, Friend)]
      shouldMatchPipe classify [bob] [(bob, Foe)] 
    it "determines undecided" $ do
      shouldMatchPipe classify  [no1] [(no1, Undecided)]

and see

>>> spec

classify
  determines friends
  determines undecided

Finished in 0.0004 seconds
2 examples, 0 failures

Note that 'classify' is an ultrafinite pipe that only allows one item to pass.

atcol commented 8 years ago

I will try this. Thanks so much @michaelt !

atcol commented 8 years ago

How do I go from Pipe a b IO () to Pipe a b Identity ()?

michaelt commented 8 years ago

Oh you can't - I was just thinking of the 'pure' case, where the pipe works for any monad, and thus for Identity or IO. Let me look at the SO question.

Gabriella439 commented 8 years ago

@atc- The simple solution is to just generalize the base monad like this:

classify :: Monad m => Pipe Person (Person, Classification) m ()

This is also the type that the compiler would infer.

That will then type-check as both Pipe Person (Person, Classification) Identity () and Pipe Person (Person, Classification) IO (), so you can use it in both a pure and impure pipeline

atcol commented 8 years ago

Hi Gabriel,

Perhaps I'm mistaken, but it seems to me that adding that contraint works when there are no actual IO actions in the pipe, but say classify did this:

classify :: Monad m => Pipe Person (Person, Classification) m ()
classify = do
    p@(Person name _) <- await
    case name of 
      "Alex" -> do 
        lift $ writeFile "Friends" "Alex"
        yield (p, Friend)
      "Bob" -> yield (p, Foe)
      _ -> yield (p, Undecided)

then we fail to compile:

 Couldn't match type `m' with `IO'
      `m' is a rigid type variable bound by
          the type signature for
            classify :: Monad m => Pipe Person (Person, Classification) m ()
          at test\WorkerSpec.hs:14:13
    Expected type: Proxy () Person () (Person, Classification) m ()
      Actual type: Proxy () Person () (Person, Classification) IO ()
    Relevant bindings include
      classify :: Pipe Person (Person, Classification) m ()
        (bound at test\WorkerSpec.hs:15:1)
    In a stmt of a 'do' block: lift $ writeFile "Friends" "Alex"

Have I misunderstood?

The pipes I've written will perform IO -- e.g. DB calls or logging -- so my overall API is in the IO monad.

Gabriella439 commented 8 years ago

Yes, if the pipe you want to test does IO then you will definitely need another approach.

Here's one way to test if a Producer matches a given list:

import Pipes
import qualified Pipes.Prelude

equals :: Eq a => [a] -> Producer a IO () -> IO Bool
equals xs p = do
    e <- next p
    case e of
        Left () -> case xs of
            [] -> return True
            _  -> return False
        Right (a, p') -> case xs of
            y:ys | a == y -> equals ys p'
            _             -> return False
atcol commented 8 years ago

Thanks, but ultimately the issue I have when using this with hspec is that having defined Pipes (not just Producers) I need to run an effect and retrieve some final result, which, I appreciate, deviates from the design goals of Pipes, e.g.:

v <- runEffect $ p1 >-> p2 >->p3
v `shouldBe` someValue  -- evaluates to a Test.Hspec.Core.Result

Obviously the above is not how Pipes works.

I strongly suspect that I have designed my program poorly or misused this library. I have written my Pipes to take input via await, do something -- write to PostgreSQL, or log something -- and yield some output. There are no Producers of single values, if you see what I mean (bar the beginning of the Pipe) -- every Pipe has to act on something and pass on its result, and it does this entirely through functions that just return a Pipe who await and yield themselves. I do not "compose" pipes from single Consumers and Producers. Perhaps if I'd done it that way (indeed thus changing the Producers here to functions of 1 parameter that yield, thus giving a Producer) then things might be more testable.

michaelt commented 8 years ago

Here's some simple-minded examples of the use of toListM to test impure producers and pipes.

import Control.Monad  
import qualified Data.Text as T
import qualified Data.Text.IO as T
import Test.Hspec

import Pipes
import qualified Pipes.Prelude as P
import qualified Pipes.Text as PT
import Pipes.Prelude.Text
import Pipes.Safe
import qualified System.IO as IO
import System.Directory

a = describe "HUnit properties:" $ do
      it "yields lines of text" $ do
          xs <- fmap T.lines $ T.readFile "pipes_hunit.hs"
          ys <- runSafeT $ P.toListM $ readFileLn "pipes_hunit.hs"
          ys `shouldBe` xs

      it "yields lines of text" $ do
          let ints = map (T.pack . show) [1..100::Int]
          T.writeFile "tmp.txt" $ T.unlines ints
          ys <- runSafeT $ P.toListM $ readFileLn "tmp.txt"
          removeFile "tmp.txt"
          ys `shouldBe` ints

      it "filters out even values" $ do
          let is = [1 ..100]:: [Int]
              evens = filter even is 
          piped <- P.toListM $ each is >-> P.filter even 
          piped `shouldBe` evens

      it "logs inputs" $ do 
        ls <- IO.withFile "tmp_numbers.txt" IO.WriteMode $ \h -> do
                  P.toListM $ each [1..10::Int] >-> logInputs h
        ls' <- fmap (map read . lines) (readFile "tmp_numbers.txt") :: IO [Int]
        ls `shouldBe` ls'
        removeFile "tmp_numbers.txt"

logInputs h = forever $ do  
  n <- await
  liftIO $ IO.hPutStrLn h (show n)
  yield n
  -- P.chain (IO.hPutStrLn h . show)
atcol commented 8 years ago

Amazing! That's exactly what I want. Funnily enough, I was looking at toListM yesterday when studying the types and had planned to try and use it for this purpose.

Thank you both for your help.