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.