hspec-effectful-0.1.0.0: src/Effectful/Hspec.hs
{-# LANGUAGE ImpredicativeTypes #-}
{-# OPTIONS_GHC -Wno-missing-role-annotations #-}
{-# OPTIONS_GHC -Wno-orphans #-}
-- |
-- Module : Effectful.Hspec
-- Copyright : (c) 2026 Institute for Digital Autonomy
-- License : EUPL-1.2
-- Maintainer : IDA
--
-- Effectful bindings for the <http://hackage.haskell.org/package/hspec Hspec library>
-- built on top of @<http://hackage.haskell.org/package/hunit-effectful hunit-effectful>@.
--
-- = Overview
--
-- This library provides @Hspec@'s test combinators expressed in terms of the 'Hspec' effect.
-- This effect allows you to intersperse test assertions with arbitrary other effects.
--
-- = Example usage
--
-- Suppose we wish to test stateful operations on an account balance:
--
-- > deposit :: (State Int :> es) => Int -> Eff es ()
-- > deposit amount = modify (+ amount)
-- >
-- > balance :: (State Int :> es) => Eff es Int
-- > balance = get
--
-- > withdraw :: (State Int :> es) => Int -> Eff es Bool
-- > withdraw amount = do
-- > funds <- balance
-- > if funds < amount
-- > then pure False
-- > else do
-- > put $ funds - amount
-- > pure True
--
-- Use 'describe' to group related examples, 'it' to state individual expectations,
-- and setup/teardown combinators such as 'before_', 'after_', or 'around_' to
-- run the examples from a known initial state:
--
-- > accountSpec :: (State Int :> es, Hspec :> es) => Eff es ()
-- > accountSpec = describe "account" . before_ (put @Int 0) $ do
-- > it "accumulates deposits" $ do
-- > deposit 100
-- > deposit 50
-- > balance `shouldReturn` 150
-- >
-- > it "checks for sufficient funds before withdrawing" $ do
-- > deposit 100
-- > withdraw 30 `shouldReturn` True
-- > withdraw 1000 `shouldReturn` False
-- > balance `shouldReturn` 70
--
-- Finally, run the spec with 'runHspec', which also resolves the underlying 'HUnit' effect:
--
-- > main :: IO ()
-- > main = runEff . runHspec . evalState @Int 0 $ accountSpec
module Effectful.Hspec
( -- * Effect
Hspec
, Expectation
, runHspecWith'
, runHspecWith
, runHspec'
, runHspec
-- * Spec construction
, describe
, context
, it
, specify
, pending
, pendingWith
-- * Skipping
, xit
, xspecify
, xdescribe
, xcontext
-- * Focusing
, focus
, fit
, fspecify
, fdescribe
, fcontext
-- * Setup / Teardown
, before_
, beforeAll_
, after_
, afterAll_
, around_
, aroundAll_
-- * Expectations
, expectationFailure
, shouldBe
, shouldSatisfy
, shouldStartWith
, shouldEndWith
, shouldContain
, shouldMatchList
, shouldReturn
, shouldNotBe
, shouldNotSatisfy
, shouldNotContain
, shouldNotReturn
, shouldThrow
, anyException
, Config (..)
, defaultConfig
, Hspec.Spec
, HasCallStack
)
where
import Control.Monad (unless)
import Data.List qualified as List
import Data.Typeable (typeOf)
import Effectful
import Effectful.Dispatch.Static
import Effectful.Environment (runEnvironment, withArgs)
import Effectful.Exception (Exception, throwIO, try)
import Effectful.HUnit as HUnit
import Test.Hspec qualified as Hspec
import Test.Hspec.Core.Spec (ResultStatus (..))
import Test.Hspec.Expectations (Selector, anyException)
import Test.Hspec.Expectations.Pretty.Matcher (matchList)
import Test.Hspec.Runner (Config (..), defaultConfig)
import Test.Hspec.Runner qualified as Hspec
import Prelude
data Hspec :: Effect
type instance DispatchOf Hspec = 'Static 'WithSideEffects
data instance StaticRep Hspec = Hspec
{ spec :: Hspec.Spec
, unlift :: forall r. Eff '[HUnit] r -> IO r
}
type Expectation es = Assertion es
runHspecWith' :: (IOE :> es, HUnit :> es) => Config -> Eff (Hspec ': es) a -> Eff es a
runHspecWith' config action = do
(a, Hspec{spec}) <- withEffToIO (ConcUnlift Ephemeral Unlimited) \unlift ->
unlift
. runStaticRep
Hspec
{ spec = pure ()
, unlift = unlift . inject
}
. inject
$ action
runEnvironment . withArgs [] . liftIO $ Hspec.hspecWith config spec
pure a
runHspecWith :: (IOE :> es) => Config -> Eff (Hspec ': es) a -> Eff es a
runHspecWith = (runHUnit .) . (. inject) . runHspecWith'
runHspec' :: (IOE :> es, HUnit :> es) => Eff (Hspec ': es) a -> Eff es a
runHspec' = runHspecWith' defaultConfig . inject
runHspec :: (IOE :> es) => Eff (Hspec ': es) a -> Eff es a
runHspec = runHUnit . runHspec' . inject
hunit :: (Hspec :> es) => Eff '[HUnit] r -> Eff es r
hunit action = do
Hspec{unlift} <- getStaticRep
unsafeEff_ . unlift . inject $ action
-- | Lifted 'Hspec.describe'
describe :: (HasCallStack, Hspec :> es) => String -> Eff es a -> Eff es a
describe label action =
stateStaticRepM \before -> do
putStaticRep before{spec = pure ()}
result <- action
after <- getStaticRep
pure (result, before{spec = spec before >> Hspec.describe label (spec after)})
-- | Lifted 'Hspec.it'
it :: (HasCallStack, Hspec :> es) => String -> Eff es () -> Eff es ()
it label action = do
ioAction <-
unsafeConcUnliftIO Ephemeral Unlimited \runInIO ->
pure (runInIO action)
stateStaticRep \hspec -> ((), hspec{spec = spec hspec >> Hspec.it label ioAction})
-- | Lifted 'Hspec.specify'
specify :: (HasCallStack, Hspec :> es) => String -> Eff es () -> Eff es ()
specify = it
-- | Lifted 'Hspec.context'
context :: (HasCallStack, Hspec :> es) => String -> Eff es a -> Eff es a
context = describe
-- | Lifted 'Hspec.pending'
pending :: (HasCallStack, Hspec :> es) => Eff es ()
pending = getStaticRep @Hspec >> unsafeEff_ Hspec.pending
pending_ :: Eff es ()
pending_ = throwIO $ Pending Nothing Nothing
-- | Lifted 'Hspec.pendingWith'
pendingWith :: (HasCallStack, Hspec :> es) => String -> Eff es ()
pendingWith = (getStaticRep @Hspec >>) . unsafeEff_ . Hspec.pendingWith
-- | Lifted 'Hspec.xit'
xit :: (HasCallStack, Hspec :> es) => String -> Eff es () -> Eff es ()
xit = (before_ pending_ .) . it
-- | Lifted 'Hspec.xspecify'
xspecify :: (HasCallStack, Hspec :> es) => String -> Eff es () -> Eff es ()
xspecify = xit
-- | Lifted 'Hspec.xdescribe'
xdescribe :: (HasCallStack, Hspec :> es) => String -> Eff es a -> Eff es a
xdescribe = (before_ pending_ .) . describe
-- | Lifted 'Hspec.xcontext'
xcontext :: (HasCallStack, Hspec :> es) => String -> Eff es a -> Eff es a
xcontext = xdescribe
-- | Lifted 'Hspec.focus'
focus :: (HasCallStack, Hspec :> es) => Eff es a -> Eff es a
focus action =
stateStaticRepM \before -> do
putStaticRep before{spec = pure ()}
result <- action
after <- getStaticRep
pure (result, before{spec = spec before >> Hspec.focus (spec after)})
-- | Lifted 'Hspec.fit'
fit :: (HasCallStack, Hspec :> es) => String -> Eff es () -> Eff es ()
fit = fmap focus . it
-- | Lifted 'Hspec.fspecify'
fspecify :: (HasCallStack, Hspec :> es) => String -> Eff es () -> Eff es ()
fspecify = fit
-- | Lifted 'Hspec.fdescribe'
fdescribe :: (HasCallStack, Hspec :> es) => String -> Eff es a -> Eff es a
fdescribe = fmap focus . describe
-- | Lifted 'Hspec.fcontext'
fcontext :: (HasCallStack, Hspec :> es) => String -> Eff es a -> Eff es a
fcontext = fdescribe
-- | Lifted 'Hspec.before_'
before_ :: (HasCallStack, Hspec :> es) => Eff es () -> Eff es a -> Eff es a
before_ setup action = do
ioSetup <-
unsafeConcUnliftIO Ephemeral Unlimited \unlift ->
pure (unlift setup)
stateStaticRepM \prev -> do
putStaticRep prev{spec = pure ()}
result <- action
after <- getStaticRep
pure (result, prev{spec = spec prev >> Hspec.before_ ioSetup (spec after)})
-- | Lifted 'Hspec.beforeAll_'
beforeAll_ :: (HasCallStack, Hspec :> es) => Eff es () -> Eff es a -> Eff es a
beforeAll_ setup action = do
ioSetup <-
unsafeConcUnliftIO Ephemeral Unlimited \unlift ->
pure (unlift setup)
stateStaticRepM \prev -> do
putStaticRep prev{spec = pure ()}
result <- action
after <- getStaticRep
pure (result, prev{spec = spec prev >> Hspec.beforeAll_ ioSetup (spec after)})
-- | Lifted 'Hspec.after_'
after_ :: (HasCallStack, Hspec :> es) => Eff es () -> Eff es a -> Eff es a
after_ teardown action = do
ioTeardown <-
unsafeConcUnliftIO Ephemeral Unlimited \unlift ->
pure (unlift teardown)
stateStaticRepM \prev -> do
putStaticRep prev{spec = pure ()}
result <- action
after <- getStaticRep
pure (result, prev{spec = spec prev >> Hspec.after_ ioTeardown (spec after)})
-- | Lifted 'Hspec.afterAll_'
afterAll_ :: (HasCallStack, Hspec :> es) => Eff es () -> Eff es a -> Eff es a
afterAll_ teardown action = do
ioTeardown <-
unsafeConcUnliftIO Ephemeral Unlimited \unlift ->
pure (unlift teardown)
stateStaticRepM \prev -> do
putStaticRep prev{spec = pure ()}
result <- action
after <- getStaticRep
pure (result, prev{spec = spec prev >> Hspec.afterAll_ ioTeardown (spec after)})
-- | Lifted 'Hspec.around_'
around_ :: (HasCallStack, Hspec :> es) => (Eff es () -> Eff es ()) -> Eff es a -> Eff es a
around_ wrapper action = do
ioWrapper <-
unsafeConcUnliftIO Ephemeral Unlimited \unlift ->
pure (unlift . wrapper . unsafeEff_)
stateStaticRepM \prev -> do
putStaticRep prev{spec = pure ()}
result <- action
after <- getStaticRep
pure (result, prev{spec = spec prev >> Hspec.around_ ioWrapper (spec after)})
-- | Lifted 'Hspec.aroundAll_'
aroundAll_ :: (HasCallStack, Hspec :> es) => (Eff es () -> Eff es ()) -> Eff es a -> Eff es a
aroundAll_ wrapper action = do
ioWrapper <-
unsafeConcUnliftIO Ephemeral Unlimited \unlift ->
pure (unlift . wrapper . unsafeEff_)
stateStaticRepM \prev -> do
putStaticRep prev{spec = pure ()}
result <- action
after <- getStaticRep
pure (result, prev{spec = spec prev >> Hspec.aroundAll_ ioWrapper (spec after)})
expectationFailure :: (HasCallStack, Hspec :> es) => String -> Expectation es
expectationFailure msg = hunit $ HUnit.assertFailure msg
expectTrue
:: (HasCallStack, Hspec :> es)
=> String
-> Bool
-> Expectation es
expectTrue msg b = unless b (expectationFailure msg)
infix 1 `shouldBe`
, `shouldSatisfy`
, `shouldStartWith`
, `shouldEndWith`
, `shouldContain`
, `shouldMatchList`
, `shouldReturn`
, `shouldThrow`
infix 1 `shouldNotBe`, `shouldNotSatisfy`, `shouldNotContain`, `shouldNotReturn`
-- |
-- @actual \`shouldBe\` expected@ sets the expectation that @actual@ is equal
-- to @expected@.
shouldBe :: (HasCallStack, Show a, Eq a, Hspec :> es) => a -> a -> Expectation es
actual `shouldBe` expected = hunit $ actual @?= expected
-- |
-- @v \`shouldSatisfy\` p@ sets the expectation that @p v@ is @True@.
shouldSatisfy :: (HasCallStack, Show a, Hspec :> es) => a -> (a -> Bool) -> Expectation es
v `shouldSatisfy` p = expectTrue ("predicate failed on: " ++ show v) (p v)
compareWith
:: (HasCallStack, Show a, Hspec :> es)
=> (a -> a -> Bool)
-> String
-> a
-> a
-> Expectation es
compareWith comparator errorDesc result expected = expectTrue errorMsg (comparator expected result)
where
errorMsg = show result ++ " " ++ errorDesc ++ " " ++ show expected
-- |
-- @list \`shouldStartWith\` prefix@ sets the expectation that @list@ starts with @prefix@,
shouldStartWith :: (HasCallStack, Show a, Eq a, Hspec :> es) => [a] -> [a] -> Expectation es
shouldStartWith = compareWith List.isPrefixOf "does not start with"
-- |
-- @list \`shouldEndWith\` suffix@ sets the expectation that @list@ ends with @suffix@,
shouldEndWith :: (HasCallStack, Show a, Eq a, Hspec :> es) => [a] -> [a] -> Expectation es
shouldEndWith = compareWith List.isSuffixOf "does not end with"
-- |
-- @list \`shouldContain\` sublist@ sets the expectation that @sublist@ is contained,
-- wholly and intact, anywhere in @list@.
shouldContain :: (HasCallStack, Show a, Eq a, Hspec :> es) => [a] -> [a] -> Expectation es
shouldContain = compareWith List.isInfixOf "does not contain"
-- |
-- @xs \`shouldMatchList\` ys@ sets the expectation that @xs@ has the same
-- elements that @ys@ has, possibly in another order
shouldMatchList :: (HasCallStack, Show a, Eq a, Hspec :> es) => [a] -> [a] -> Expectation es
xs `shouldMatchList` ys = maybe (return ()) expectationFailure (matchList xs ys)
-- |
-- @action \`shouldReturn\` expected@ sets the expectation that @action@
-- returns @expected@.
shouldReturn :: (HasCallStack, Show a, Eq a, Hspec :> es) => Eff es a -> a -> Expectation es
action `shouldReturn` expected = action >>= (`shouldBe` expected)
-- |
-- @actual \`shouldNotBe\` notExpected@ sets the expectation that @actual@ is not
-- equal to @notExpected@
shouldNotBe :: (HasCallStack, Show a, Eq a, Hspec :> es) => a -> a -> Expectation es
actual `shouldNotBe` notExpected = expectTrue ("not expected: " ++ show actual) (actual /= notExpected)
-- |
-- @v \`shouldNotSatisfy\` p@ sets the expectation that @p v@ is @False@.
shouldNotSatisfy :: (HasCallStack, Show a, Hspec :> es) => a -> (a -> Bool) -> Expectation es
v `shouldNotSatisfy` p = expectTrue ("predicate succeeded on: " ++ show v) ((not . p) v)
-- |
-- @list \`shouldNotContain\` sublist@ sets the expectation that @sublist@ is not
-- contained anywhere in @list@.
shouldNotContain :: (HasCallStack, Show a, Eq a, Hspec :> es) => [a] -> [a] -> Expectation es
list `shouldNotContain` sublist = expectTrue errorMsg (not $ sublist `List.isInfixOf` list)
where
errorMsg = show list ++ " does contain " ++ show sublist
-- |
-- @action \`shouldNotReturn\` notExpected@ sets the expectation that @action@
-- does not return @notExpected@.
shouldNotReturn
:: (HasCallStack, Show a, Eq a, Hspec :> es)
=> Eff es a
-> a
-> Expectation es
action `shouldNotReturn` notExpected = action >>= (`shouldNotBe` notExpected)
-- |
-- @action \`shouldThrow\` selector@ sets the expectation that @action@ throws
-- an exception. The precise nature of the expected exception is described
-- with a 'Selector'.
shouldThrow
:: (HasCallStack, Exception e, Hspec :> es)
=> Eff es a
-> Selector e
-> Expectation es
action `shouldThrow` p = do
r <- try action
case r of
Right _ ->
expectationFailure $
"did not get expected exception: " ++ exceptionType
Left e ->
(`expectTrue` p e) $
"predicate failed on expected exception: " ++ exceptionType ++ "\n" ++ show e
where
-- a string representation of the expected exception's type
exceptionType = (show . typeOf . instanceOf) p
where
instanceOf :: Selector a -> a
instanceOf _ = error "Effectful.Hspec.shouldThrow: broken Typeable instance"