I've been porting a codebase from monad-mock where we've been using the MonadTransControl instance. That's not available for HMock. Currently I've got an orphan instance of it that's definitely not safe in general, but that works in the places where I use it. But I think it might be possible to provide a function that's basically liftWith specialized to MockT, that could be used safely.
To illustrate, here's the function in question, and the code I'm testing it with:
import qualified Test.HMock.Internal.State as HMockI
-- And a bunch of other imports
-- | Basically `liftWith`
liftMockWith
:: forall m a
. ((forall n b . Coercible m n => MockT n b -> n b) -> m a)
-> MockT m a
liftMockWith run = HMockI.MockT $ ReaderT $ \state ->
let runArg :: forall n b . MockT n b -> n b
runArg =
let changeState :: HMockI.MockState m -> HMockI.MockState n
changeState (HMockI.MockState {..}) = HMockI.MockState
{ mockExpectSet = unsafeCoerce mockExpectSet
, mockDefaults = unsafeCoerce mockDefaults
, mockAllowUnexpected = unsafeCoerce mockAllowUnexpected
, mockSideEffects = unsafeCoerce mockSideEffects
, mockParent = unsafeCoerce mockParent
, ..
}
in flip runReaderT (changeState state) . HMockI.unMockT
in run runArg
-- Test it:
class Monad m => MonadFilesystem m where
readF :: FilePath -> m String
writeF :: FilePath -> String -> m ()
copyFile :: MonadFilesystem m => FilePath -> FilePath -> m ()
copyFile a b = readF a >>= writeF b
makeMockable [t|MonadFilesystem|]
spec :: Spec
spec =
it "Test mock" $ do
return () :: IO ()
runIdentityT $ do
return () :: IdentityT IO ()
HMock.runMockT $ do
return () :: HMock.MockT (IdentityT IO) ()
expect $ ReadF "foo.txt" |-> "contents"
expect $ WriteF "bar.txt" "contents" |-> ()
liftMockWith $ \run -> do
return () :: IdentityT IO ()
liftIO $ run $ do
copyFile "foo.txt" "bar.txt" :: MockT IO ()
I've included some type annotations partly as a hint to the typechecker and partly to make it a bit clearer what's going on.
This works fine, despite the unsafeCoerce. We can also move one or both expect calls to directly above copyFile, inside the run block.
We can use the same basic function to implement an orphan instance of MonadTransControl. (With StT MockT a = a and restoreT = lift.) And then if we replace liftMockWith in that code with liftWith, there's still no problem. (At least not at -O0. There's a lot of edge cases I haven't tested thoroughly yet.)
But it doesn't work if we replace IdentityT with a version of itself that's been implemented with data instead of newtype. That is, instead of importing IdentityT, define
data IdentityT m a = IdentityT { runIdentityT :: m a }
deriving stock Functor
instance Applicative m => Applicative (IdentityT m) where
pure = IdentityT . pure
IdentityT f <*> IdentityT x = IdentityT $ f <*> x
instance Monad m => Monad (IdentityT m) where
IdentityT x >>= f = IdentityT $ x >>= runIdentityT . f
instance MonadIO m => MonadIO (IdentityT m) where
liftIO = IdentityT . liftIO
and suddenly we get a segfault.
uncaught exception: ErrorCall
Wrong arguments: writeF "bar.txt" "\1099511628032zsh: segmentation fault (core dumped) cabal run $GHC_LINK_OPTS -O0 testnofork -- -m "Test mock"
Presumably we've gone from doing unsafeCoerce between things that are coercible, to between things that aren't. And if we go back to liftMockWith, using our data-based IdentityT, we get a comile error:
• Couldn't match representation of type ‘IdentityT IO’
with that of ‘IO’
arising from a use of ‘run’
The data constructor ‘ghc-prim-0.6.1:GHC.Types.IO’
of newtype ‘IO’ is not in scope
(For either version of IdentityT, and either liftWith or liftMockWith, we can remove the liftIO and it works. Then the type of the copyFile line is MockT (IdentityT IO) (), and we're coercing between a thing and itself.)
So a big question here is, is that unsafeCoerce actually safe? Ultimately the things we're coercing are all either TVar [Step {m/n}] or TVar (ExpectSet (Step {m/n})). Step has type role nominal, so coerce isn't allowed unless m ~ n, but that doesn't prove it's unsafe. I think the thing I'd be most worried about is it has a Typeable m constraint. But I don't currently know where that comes from or if it's a problem here.
If the unsafeCoerce actually is unsafe, then another option might be to restrict the type further, to m ~ n - at that point it looks similar to withMockT, I'm not sure what the relationship is between them but maybe withMockT already gives us everything we're going to get here.
liftMockWith :: ((forall a . MockT m a -> m a) -> m b)
withMockT :: MonadIO m => ((forall a . MockT m a -> m a) -> MockT m b) -> m b
(It's also possible withMockT will be sufficient for what I want to do. I don't currently think so, but I haven't fully explored the possibility. edit: looked into it now, I'm pretty sure it won't.)
Sorry this is kind of rambling. I guess the main questions I'm wondering here are
- Does the
liftMockWith implementation above seem safe?
- Would you consider adding it?
I've been porting a codebase from monad-mock where we've been using the
MonadTransControlinstance. That's not available for HMock. Currently I've got an orphan instance of it that's definitely not safe in general, but that works in the places where I use it. But I think it might be possible to provide a function that's basicallyliftWithspecialized toMockT, that could be used safely.To illustrate, here's the function in question, and the code I'm testing it with:
I've included some type annotations partly as a hint to the typechecker and partly to make it a bit clearer what's going on.
This works fine, despite the
unsafeCoerce. We can also move one or bothexpectcalls to directly abovecopyFile, inside therunblock.We can use the same basic function to implement an orphan instance of
MonadTransControl. (WithStT MockT a = aandrestoreT = lift.) And then if we replaceliftMockWithin that code withliftWith, there's still no problem. (At least not at-O0. There's a lot of edge cases I haven't tested thoroughly yet.)But it doesn't work if we replace
IdentityTwith a version of itself that's been implemented withdatainstead ofnewtype. That is, instead of importingIdentityT, defineand suddenly we get a segfault.
Presumably we've gone from doing
unsafeCoercebetween things that are coercible, to between things that aren't. And if we go back toliftMockWith, using our data-basedIdentityT, we get a comile error:(For either version of
IdentityT, and eitherliftWithorliftMockWith, we can remove theliftIOand it works. Then the type of thecopyFileline isMockT (IdentityT IO) (), and we're coercing between a thing and itself.)So a big question here is, is that
unsafeCoerceactually safe? Ultimately the things we're coercing are all eitherTVar [Step {m/n}]orTVar (ExpectSet (Step {m/n})).Stephas type role nominal, socoerceisn't allowed unlessm ~ n, but that doesn't prove it's unsafe. I think the thing I'd be most worried about is it has aTypeable mconstraint. But I don't currently know where that comes from or if it's a problem here.If the
unsafeCoerceactually is unsafe, then another option might be to restrict the type further, tom ~ n- at that point it looks similar towithMockT, I'm not sure what the relationship is between them but maybewithMockTalready gives us everything we're going to get here.(It's also possible
withMockTwill be sufficient for what I want to do. I don't currently think so, but I haven't fully explored the possibility. edit: looked into it now, I'm pretty sure it won't.)Sorry this is kind of rambling. I guess the main questions I'm wondering here are
liftMockWithimplementation above seem safe?