Use custom monad for more principled test failure handling

This commit is contained in:
George Thomas 2026-01-07 00:39:35 +00:00
parent 39714ff1c3
commit 3721c27c32
3 changed files with 57 additions and 32 deletions

View File

@ -4,6 +4,7 @@ import Pre
import Data.Finite import Data.Finite
import Data.Functor.Contravariant import Data.Functor.Contravariant
import Data.Text qualified as T
import Data.Text.IO qualified as T import Data.Text.IO qualified as T
import Data.Text.Lazy.IO qualified as TL import Data.Text.Lazy.IO qualified as TL
import Puzzles.Day1 qualified as Day1 import Puzzles.Day1 qualified as Day1
@ -47,9 +48,9 @@ tests =
( \() -> do ( \() -> do
let fp = "../inputs/" <> t <> "/" <> pt let fp = "../inputs/" <> t <> "/" <> pt
input <- input <-
either (fail . ("parse failure: " <>) . errorBundlePretty) pure either (assertFailure . T.pack . ("parse failure: " <>) . errorBundlePretty) pure
. runParser (parser isRealData <* eof) fp . runParser (parser isRealData <* eof) fp
=<< T.readFile fp =<< liftIO (T.readFile fp)
let (rs, os) = let (rs, os) =
(lookupHList fst &&& foldHListF (HCons . snd) HNil) $ (lookupHList fst &&& foldHListF (HCons . snd) HNil) $
mapHListF (\((Fanout (f, Op o))) -> (o &&& id) $ f input) parts mapHListF (\((Fanout (f, Op o))) -> (o &&& id) $ f input) parts

View File

@ -64,6 +64,7 @@ module Pre (
Fanout (..), Fanout (..),
Length, Length,
TestTree, TestTree,
Test,
test, test,
testLazy, testLazy,
TestName, TestName,
@ -73,6 +74,7 @@ module Pre (
runTests, runTests,
assertEqual, assertEqual,
assert, assert,
assertFailure,
golden, golden,
) )
where where
@ -95,7 +97,8 @@ import Control.DeepSeq (NFData, deepseq)
import Control.DeepSeq qualified as DeepSeq import Control.DeepSeq qualified as DeepSeq
import Control.Exception (SomeException, evaluate) import Control.Exception (SomeException, evaluate)
import Control.Monad import Control.Monad
import Control.Monad.Catch (MonadCatch, try) import Control.Monad.Catch (MonadCatch, MonadThrow, try)
import Control.Monad.Except
import Control.Monad.Loops import Control.Monad.Loops
import Control.Monad.State import Control.Monad.State
import Data.Bifunctor import Data.Bifunctor
@ -248,22 +251,39 @@ data TestTree m input where
TestTree :: TestName -> TestCase m input output -> [TestTree m output] -> TestTree m input TestTree :: TestName -> TestCase m input output -> [TestTree m output] -> TestTree m input
data TestCase m input output where data TestCase m input output where
TestCase :: (NFData output) => (input -> m output) -> TestCase m input output TestCase :: (NFData output) => (input -> Test m output) -> TestCase m input output
TestCaseLazy :: (input -> m output) -> TestCase m input output TestCaseLazy :: (input -> Test m output) -> TestCase m input output
newtype Test m a = Test (ExceptT TestFailure m a)
deriving newtype
( Functor
, Applicative
, Monad
, MonadIO
, MonadThrow
, MonadCatch
, MonadError TestFailure
)
-- | See `testLazy` for avoiding the `NFData` constraint. -- | See `testLazy` for avoiding the `NFData` constraint.
test :: (NFData output) => TestName -> (input -> m output) -> [TestTree m output] -> TestTree m input test :: (NFData output) => TestName -> (input -> Test m output) -> [TestTree m output] -> TestTree m input
test n f = TestTree n $ TestCase f test n f = TestTree n $ TestCase f
{- | This is `test` without the `NFData` constraint. {- | This is `test` without the `NFData` constraint.
It doesn't force the output before completion, which means that reported timings may be less accurate. It doesn't force the output before completion, which means that reported timings may be less accurate.
-} -}
testLazy :: TestName -> (input -> m output) -> [TestTree m output] -> TestTree m input testLazy :: TestName -> (input -> Test m output) -> [TestTree m output] -> TestTree m input
testLazy n f = TestTree n $ TestCaseLazy f testLazy n f = TestTree n $ TestCaseLazy f
data TestResult data TestResult
= Pass TestName NominalDiffTime [TestResult] = Pass TestName NominalDiffTime [TestResult]
| Fail TestName SomeExceptionLegalShow | Fail TestName TestFailure
deriving (Show)
data TestFailure
= ExceptionFailure SomeException
| AssertionFailure Text
| GoldenFailure {expected :: Text, actual :: Text}
deriving (Show) deriving (Show)
newtype TestName = TestName String newtype TestName = TestName String
@ -272,10 +292,6 @@ newtype TestName = TestName String
mkTestName :: String -> TestName mkTestName :: String -> TestName
mkTestName = TestName mkTestName = TestName
newtype SomeExceptionLegalShow = SomeExceptionLegalShow SomeException
instance Show SomeExceptionLegalShow where
show (SomeExceptionLegalShow e) = show $ show e
getTestTree :: TestTree m r -> Tree TestName getTestTree :: TestTree m r -> Tree TestName
getTestTree (TestTree name _ ts) = Node name $ map getTestTree ts getTestTree (TestTree name _ ts) = Node name $ map getTestTree ts
@ -288,13 +304,16 @@ displayTestResultsConsole terminalWidth testResult =
Pass (TestName name) dt children -> Pass (TestName name) dt children ->
TL.fromStrict (header Green '✓' name indent (Just dt)) TL.fromStrict (header Green '✓' name indent (Just dt))
<> TL.concat (map (displayResult (indent + 1)) children) <> TL.concat (map (displayResult (indent + 1)) children)
Fail (TestName name) (SomeExceptionLegalShow e) -> Fail (TestName name) e ->
TL.fromStrict TL.fromStrict $
( header Red '✗' name indent Nothing header Red '✗' name indent Nothing
<> setColour Vivid Red <> setColour Vivid Red
) <> case e of
<> TL.show e ExceptionFailure ex -> T.show ex
<> "\n" AssertionFailure t -> T.stripEnd t
GoldenFailure{expected, actual} ->
"Expected:\n" <> T.stripEnd expected <> "\nActual:\n" <> T.stripEnd actual
<> "\n"
header colour icon name indent time = header colour icon name indent time =
setColour Vivid colour setColour Vivid colour
<> T.singleton icon <> T.singleton icon
@ -347,11 +366,14 @@ displayTestResultsConsole terminalWidth testResult =
runTests :: (MonadIO m, MonadCatch m) => a -> TestTree m a -> m TestResult runTests :: (MonadIO m, MonadCatch m) => a -> TestTree m a -> m TestResult
runTests r0 (TestTree name tc ts) = runTests r0 (TestTree name tc ts) =
Control.Monad.Catch.try (runTest tc) >>= \case let Test t = Control.Monad.Catch.try $ runTest tc
Left e -> in runExceptT t >>= \case
pure $ Fail name $ SomeExceptionLegalShow e Left e ->
Right (r, dt) -> pure $ Fail name e
Pass name dt <$> for ts (runTests r) Right (Left e) ->
pure $ Fail name $ ExceptionFailure e
Right (Right (r, dt)) ->
Pass name dt <$> for ts (runTests r)
where where
runTest = \case runTest = \case
TestCase f -> timed (liftIO . evaluate . DeepSeq.force) $ f r0 TestCase f -> timed (liftIO . evaluate . DeepSeq.force) $ f r0
@ -363,11 +385,13 @@ runTests r0 (TestTree name tc ts) =
t1 <- liftIO getCurrentTime t1 <- liftIO getCurrentTime
pure (rf, diffUTCTime t1 t0) pure (rf, diffUTCTime t1 t0)
assertEqual :: (Eq p, MonadFail m) => p -> p -> m () assertEqual :: (Eq p, Monad m) => p -> p -> Test m ()
assertEqual expected actual = assert "not equal" (expected == actual) assertEqual expected actual = assert "not equal" (expected == actual)
assert :: (MonadFail m) => String -> Bool -> m () assert :: (Monad m) => Text -> Bool -> Test m ()
assert s b = if b then pure () else fail s assert s b = if b then pure () else assertFailure s
golden :: FilePath -> Text -> IO () assertFailure :: (Monad m) => Text -> Test m a
golden p x = do assertFailure = throwError . AssertionFailure
expected <- T.readFile p golden :: (MonadIO m, MonadFail m) => FilePath -> Text -> Test m ()
if expected == x then pure () else fail "golden test failure" golden p actual = do
expected <- liftIO $ T.readFile p
if expected == actual then pure () else throwError $ GoldenFailure{expected, actual}

View File

@ -25,7 +25,7 @@ puzzle =
[ test [ test
"round trip" "round trip"
( \(input, _) -> do ( \(input, _) -> do
t <- T.readFile if isRealData then "../inputs/real/4" else "../inputs/examples/4" t <- liftIO $ T.readFile if isRealData then "../inputs/real/4" else "../inputs/examples/4"
assertEqual t $ drawGrid (mkGrid input <&> \case InEmpty -> OutEmpty; InRoll -> OutRoll) assertEqual t $ drawGrid (mkGrid input <&> \case InEmpty -> OutEmpty; InRoll -> OutRoll)
) )
[] []
@ -36,7 +36,7 @@ puzzle =
) )
let nFrames = if isRealData then 58 else 9 let nFrames = if isRealData then 58 else 9
lookupFrame n frames = lookupFrame n frames =
maybe (fail $ "frame list index not found: " <> show n) pure $ maybe (assertFailure $ "frame list index not found: " <> T.show n) pure $
Seq.lookup n frames Seq.lookup n frames
in map in map
( \n -> ( \n ->