简体   繁体   中英

Haskell exceptions and unit testing

Based on SO question 13350164 How do I test for an error in Haskell? , I'm trying to write a unit test which asserts that given invalid input, a recursive function raises an exception. The approach I adopted works well for non-recursive functions (or when the first call raises the exception), but as soon as the exception occurs deeper in the call chain, the assertion fails.

I've read the excellent answers to question 6537766 Haskell approaches to error handling but unfortunately the advice is a bit too generic for this point of my learning curve. My guess is that the problem here is connected to lazy evaluation and non-pure testing code, but I'd appreciate an expert explanation.

Should I take a different approach to error handling in situations like this (eg Maybe or Either ), or is there a reasonable fix for making the test case work correctly while using this style?

Here's the code I've come up with. The first two test cases succeed, but the third one fails with "Received no exception, but was expecting exception: Negative item" .

import Control.Exception (ErrorCall(ErrorCall), evaluate)
import Test.HUnit.Base  ((~?=), Test(TestCase, TestList))
import Test.HUnit.Text (runTestTT)
import Test.HUnit.Tools (assertRaises)

sumPositiveInts :: [Int] -> Int
sumPositiveInts [] = error "Empty list"
sumPositiveInts (x:[]) = x
sumPositiveInts (x:xs) | x >= 0 = x + sumPositiveInts xs
                       | otherwise = error "Negative item"

instance Eq ErrorCall where
    x == y = (show x) == (show y)

assertError msg ex f = 
    TestCase $ assertRaises msg (ErrorCall ex) $ evaluate f

tests = TestList [
  assertError "Empty" "Empty list" (sumPositiveInts ([]))
  , assertError "Negative head" "Negative item" (sumPositiveInts ([-1, -1]))
  , assertError "Negative second item" "Negative item" (sumPositiveInts ([1, -1]))
  ]   

main = runTestTT tests

It's actually just an error in sumPositiveInts . Your code does not do negativity checking when the only negative number is the last one in the list—the second branch doesn't include the check.

It's worth noting that the canonical way of writing recursion like yours would break the "emptiness" test out in order to avoid this bug. Generally, decomposing your solution into "sum" plus two guards will help to avoid errors.


I second the suggestion from Haskell approaches to error handling by the way. Control.Exception is much more difficult to reason about and learn and error should only be used to mark code branches which are impossible to achieve—I rather like some suggestions that it ought to have been called impossible .

To make the suggestion tangible, we can rebuild this example using Maybe . First, the unguarded function is built in:

sum :: Num a => [a] -> a

then we need to build the two guards (1) empty lists give Nothing and (2) lists containing negative numbers give Nothing .

emptyIsNothing :: [a] -> Maybe [a]
emptyIsNothing [] = Nothing
emptyIsNothing as = Just as

negativeGivesNothing :: [a] -> Maybe [a]
negativeGivesNothing xs | all (>= 0) xs = Just xs
                        | otherwise     = Nothing

and we can combine them as a monad

sumPositiveInts :: [a] -> Maybe a
sumPositiveInts xs = do xs1 <- emptyIsNothing xs
                        xs2 <- negativeGivesNothing xs1
                        return (sum xs2)

And then there are lots of idioms and reductions we can employ to make this code much easier to read and write (once you know the conventions!). Let me stress that nothing after this point is necessary nor terribly easy to understand. Learning it improves your ability to decompose functions and fluently think about FP, but I'm just jumping to the advanced stuff.

For instance, we can use "Monadic (.) " (which is also called Kleisli arrow composition) to write sumPositiveInts

sumPositiveInts :: [a] -> Maybe a
sumPositiveInts = emptyIsNothing >=> negativeGivesNothing >=> (return . sum)

and we can simplify both emptyIsNothing and negativeGivesNothing using a combinator

elseNothing :: (a -> Bool) -> a -> Just a
pred `elseNothing` x | pred x    = Just x
                     | otherwise = Nothing

emptyIsNothing = elseNothing null

negativeGivesNothing = sequence . map (elseNothing (>= 0))

where sequence :: [Maybe a] -> Maybe [a] fails an entire list if any of the contained values are Nothing . We can actually go one step further since sequence . map f sequence . map f is a common idiom

negativeGivesNothing = mapM (elseNothing (>= 0))

So, in the end

sumPositives :: [a] -> Maybe a
sumPositives = elseNothing null 
               >=> mapM (elseNothing (>= 0))
               >=> return . sum

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM