mockcat-0.5.4.0: src/Test/MockCat/MockT.hs
{-# LANGUAGE ExistentialQuantification #-}
{-# LANGUAGE DeriveFunctor #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# OPTIONS_GHC -Wno-name-shadowing #-}
module Test.MockCat.MockT (MockT(..), Definition(..), runMockT, applyTimesIs, neverApply, MonadMockDefs(..)) where
import Control.Monad.IO.Class (MonadIO(..))
import Control.Monad.Trans.Class (MonadTrans(..))
import Control.Monad.Reader (ReaderT(..), runReaderT)
import GHC.TypeLits (KnownSymbol)
import Data.Data (Proxy)
import Test.MockCat.Mock (Mock, shouldApplyTimesToAnything)
import Data.Foldable (for_)
import UnliftIO (MonadUnliftIO(..))
import Data.IORef (IORef, newIORef, readIORef, atomicModifyIORef')
{- | MockT is a thin wrapper over @ReaderT (IORef [Definition])@ providing
mock/stub registration and post-run verification.
Concurrency safety (summary):
* Within a single 'runMockT' invocation, concurrent applications of stub
functions are recorded without lost or double counts. This is achieved via
atomic modifications ('atomicModifyIORef'').
* The /moment/ a call is recorded is when the stub's return value is evaluated;
if you only create an application but never force the result, it will not
appear in the verification log.
* Order-sensitive checks reflect evaluation order, not necessarily wall-clock
start order between threads.
* Perform verification (e.g. 'shouldApplyTimes', 'applyTimesIs') after all
parallel work has completed; running it mid-flight may observe fewer calls
simply because some results are still lazy.
* Each 'runMockT' call uses a fresh IORef store; mocks are not shared across
separate 'runMockT' boundaries.
-}
newtype MockT m a = MockT { unMockT :: ReaderT (IORef [Definition]) m a }
deriving (Functor, Applicative, Monad, MonadTrans, MonadIO)
class Monad m => MonadMockDefs m where
addDefinition :: Definition -> m ()
getDefinitions :: m [Definition]
instance MonadUnliftIO m => MonadUnliftIO (MockT m) where
withRunInIO inner = MockT $ ReaderT $ \ref ->
withRunInIO $ \run -> inner (\(MockT r) -> run (runReaderT r ref))
data Definition = forall f p sym. KnownSymbol sym => Definition {
symbol :: Proxy sym,
mock :: Mock f p,
verify :: Mock f p -> IO ()
}
{- | Run MockT monad.
After run, verification is performed to see if the stub function has been applied.
@
import Test.Hspec
import Test.MockCat
...
class (Monad m) => FileOperation m where
writeFile :: FilePath -\> Text -\> m ()
readFile :: FilePath -\> m Text
operationProgram ::
FileOperation m =\>
FilePath -\>
FilePath -\>
m ()
operationProgram inputPath outputPath = do
content \<- readFile inputPath
writeFile outputPath content
makeMock [t|FileOperation|]
spec :: Spec
spec = do
it "test runMockT" do
result \<- runMockT do
_readFile $ "input.txt" |\> pack "content"
_writeFile $ "output.text" |\> pack "content" |\> ()
operationProgram "input.txt" "output.text"
result `shouldBe` ()
@
-}
runMockT :: MonadIO m => MockT m a -> m a
runMockT (MockT r) = do
ref <- liftIO $ newIORef []
a <- runReaderT r ref
defs <- liftIO $ readIORef ref
for_ defs (\(Definition _ m v) -> liftIO $ v m)
pure a
{- | Specify how many times a stub function (or group of stub definitions) must
be applied (to /any/ arguments). The function patches the verification
predicate for the provided stub definitions so that, after 'runMockT'
completes, the total number of evaluated applications is checked.
Concurrency & laziness notes:
* Counting is thread-safe: each evaluated application contributes exactly 1.
* An application is only counted once its return value is evaluated; ensure
your test forces (e.g. via @shouldBe@ or sequencing) all stub results
before relying on the count.
* Invoke 'applyTimesIs' inside the 'runMockT' block during setup; do not
call it after the block ends.
@
import Test.Hspec
import Test.MockCat
...
class (Monad m) => FileOperation m where
writeFile :: FilePath -\> Text -\> m ()
readFile :: FilePath -\> m Text
operationProgram ::
FileOperation m =>
FilePath ->
FilePath ->
m ()
operationProgram inputPath outputPath = do
content <- readFile inputPath
when (content == pack "ng") $ writeFile outputPath content
makeMock [t|FileOperation|]
spec :: Spec
spec = do
it "test runMockT" do
result <- runMockT do
_readFile ("input.txt" |> pack "content")
_writeFile ("output.text" |> pack "content" |> ()) `applyTimesIs` 0
operationProgram "input.txt" "output.text"
result `shouldBe` ()
@
-}
applyTimesIs :: MonadIO m => MockT m () -> Int -> MockT m ()
applyTimesIs (MockT inner) a = MockT $ ReaderT $ \ref -> do
tmp <- liftIO $ newIORef []
_ <- runReaderT inner tmp
defs <- liftIO $ readIORef tmp
let patched = map (\(Definition s m _) -> Definition s m (`shouldApplyTimesToAnything` a)) defs
liftIO $ atomicModifyIORef' ref (\xs -> (xs ++ patched, ()))
pure ()
neverApply :: MonadIO m => MockT m () -> MockT m ()
neverApply (MockT inner) = MockT $ ReaderT $ \ref -> do
tmp <- liftIO $ newIORef []
_ <- runReaderT inner tmp
defs <- liftIO $ readIORef tmp
let patched = map (\(Definition s m _) -> Definition s m (`shouldApplyTimesToAnything` 0)) defs
liftIO $ atomicModifyIORef' ref (\xs -> (xs ++ patched, ()))
pure ()
instance MonadIO m => MonadMockDefs (MockT m) where
addDefinition d = MockT $ ReaderT $ \ref -> liftIO $ atomicModifyIORef' ref (\xs -> (xs ++ [d], ()))
getDefinitions = MockT $ ReaderT $ \ref -> liftIO $ readIORef ref
instance MonadIO m => MonadMockDefs (ReaderT (IORef [Definition]) m) where
addDefinition d = ReaderT $ \ref -> liftIO $ atomicModifyIORef' ref (\xs -> (xs ++ [d], ()))
getDefinitions = ReaderT $ \ref -> liftIO $ readIORef ref