HMock provides a flexible and composable mock framework for Haskell, with functionality that generally matches or exceeds that of Mockito for Java, GoogleMock for C++, and other mainstream languages.
WARNING: Hmock's API is likely to change soon. Please ensure you use an upper bound on the version number. The current API works fine for mocking with MTL-style classes. I want HMock to also work with effect systems, servant, haxl, and more. To accomplish this, I'll need to make breaking changes to the API.
-
Define classes for the functionality you need to mock. To mock anything with HMock, it needs to be implemented using a
Monad
subclass.import Prelude hiding (readFile, writeFile) import qualified Prelude class Monad m => MonadFilesystem m where readFile :: FilePath -> m String writeFile :: FilePath -> String -> m () instance MonadFilesystem IO where readFile = Prelude.readFile writeFile = Prelude.writeFile
-
Implement the code to test, using this class.
copyFile :: MonadFilesystem m => FilePath -> FilePath -> m () copyFile a b = readFile a >>= writeFile b
-
Make the class
Mockable
using the provided Template Haskell splices.makeMockable [t|MonadFilesystem|]
-
Set up expectations and run your code.
test_copyFile :: IO () test_copyFile = runMockT $ do expect $ ReadFile "foo.txt" |-> "contents" expect $ WriteFile "bar.txt" "contents" copyFile "foo.txt" "bar.txt"
runMockT
runs code in theMockT
monad transformer.expect
expects a method to be called exactly once.ReadFile
andWriteFile
match the function calls. They are defined bymakeMockable
.|->
separates the method call from its result. If it's left out, the method will return a default value (seeData.Default
), so there's no need to specify()
as a return value.
Mocks are not always the right tool for the job, but they play an important role in testing practice.
-
If possible, we prefer to test with actual code. Haskell encourages writing much of the application logic with pure functions, which can be trivially tested. However, this isn't all of the code, and bugs are quite likely to appear in glue code that connects the core application logic to its outside effects.
-
If testing the actual code is not possible, we prefer to test with high quality fake implementations. These work well for relatively simple effects. However, they are difficult to maintain when the behavior of an external system is complex, poorly specified, and/or frequently changing. Incomplete or oversimplified fakes can make some of the most bug-prone code, such as error handling and unusual cases, very difficult to test.
-
Use of a mock framework allows a programmer to test code that uses complex effectful interfaces, including all of the dark corners where nasty bugs tend to hide. They also help to isolate test failures: when a component is broken, one test fails and is easy to find, rather than everything downstream failing at once.
HMock was designed to help Haskell programmers adopt good habits when testing with mocks. When testing with mocks, there are some dangers to look out for:
-
Over-assertion happens when your test requires things you don't care about. If you read two files, you usually don't care in which order they are read, so your tests should not require an order. Even when your code needs to behave a certain way, you usually don't need to check that in every single test. Each test should ideally test one property. However, a simplistic approach to mocks may force you to over-assert just to run your code at all.
-
Over-stubbing happens when you remove too much functionality from your code, and end up assuming part of the logic you wanted to test. This makes your test less useful. Again, a simplistic approach to mocks can lead you to stub too much by not providing the right options to get realistic behavior from your methods.
-
Fragile tests happen when your expectations match too often or at unexpected times, leading to incorrect behavior that's merely an artifact of problems with mocks. Mainstream mock frameworks are not particularly compositional, leading to frequent struggles with unexpected rules firing at off times and breaking other tests.
HMock is designed to help you avoid these mistakes, by offering:
With HMock, you choose which constraints to enforce on the order of methods.
If certain methods need to happen in a fixed sequence, you can use inSequence
to check that. But if you don't care about the order, you need not check it.
If you don't care about certain methods at all, expectAny
will let you set a
response without limiting when they are called. Using expectN
, you can make
a method optional, or limit the number of times it can occur.
These tools let you express more of the exact properties you intend to test, so that you don't fall into the over-assertion trap. This also unlocks the opportunity to test concurrent or otherwise non-deterministic code.
In HMock, you specify exactly what you care about in method parameters, by
using Predicate
s. A Predicate a
is essentially a -> Bool
, except that it
can be printed for better error messages. If you want to match all parameters
exactly, there's a shortcut for doing so. But you can also ignore arguments you
don't care about, or only make partial assertions about their values. For
example, you can use hasSubstr
to match a key word in a logging message,
without needing to copy and paste the entire string into your test.
Because you need not compare every argument, HMock can even be used to mock
methods whose parameters have no Eq
instances at all. You can write a mock
for a method that takes a function as an argument, for example. You can even
mock polymorphic methods.
In HMock, you have a lot of options for what to do when a method is called. For example:
- You can look at the arguments. Need to return the third argument? No
problem; just look at the
Action
that's passed in. - You can invoke other methods. Need to forward one method to another? Want to set up a lightweight fake without defining a new type and instance? It's easy to do so.
- You can add additional expectations. Need to be sure that every opened file
handle is closed? The response runs in
MockT
, so just add that expectation when the handle is opened. - You can perform actions in a base monad. Need to modify some state for a
complex test? Need to keep a log of info so that you can assert a property
at the end of the test? Just run your test in
MockT (State Foo)
orMockT (Writer [Info])
, and callget
,put
, andtell
from your responses.
These flexible responses help you to avoid over-stubbing. You can even use HMock to delegate to a lightweight fake. Not only does this avoid defining a new type for each fake instance, but you can also easily inject errors and other unusual behavior as exceptions to the fake implementation.
HMock's expectations expand on the ideas from Svenningsson, et al. in An Expressive Semantics of Mocking. The key idea is to offer compositional primitives. Anything you can do to a single call, you can also do to an entire sequence of calls. You can express repeated sequences of calls, choice between two options, etc. Because the core mocking language is more expressive, you're less likely to need to stub out to secondary mechanisms, such as recording calls and asserting about them later, the way you may be used to in other languages, and this also makes your tests more composable.
With HMock, your mocks are independent of the specific monad stack or
combination of interfaces that your code uses. You can write tests using any
combination of Mockable
classes, and each part of your test code depends only
on the classes that you use directly. This frees you to share convenience
libraries for testing, and reuse these components in different combinations
as needed.
You can also set up default behaviors for your mocks by implementing the
Mockable
class manually, bundling sensible defaults with your derived mock
implementations for all users.
HMock allows you to control the severity of several situations that can arise
during testing. You can modify each of these situations to be Ignore
d,
trigger a Warning
but continue the test, or throw an Error
, by passing the
severity to a MockT
action as explained below.
These conditions whose severity you can adjust are:
-
Ambiguous expectations
Ambiguious expectations happen when one action matches more than one expectation. By default, this is
Ignore
d, and the most recently added expectation is chosen.Svenningsson, et al. argue that ambiguity resolution rules are non-composable and that enforcing ambiguity is therefore necessary for composability. If you agree, you may want to set a higher severity for ambiguous expectations. To do so, use
setAmbiguityCheck
.Note that unlike Svenningsson, et al., HMock verifies ambiguity dynamically at runtime, so failures only occur when an actual action matches more than one expectation., and not when it's merely possible for this to occur.
-
Uninteresting actions
An uninteresting action occurs when a method is called, but no expectations have been added for that method at all. By default, this is an
Error
. However, some other mock frameworks (for example, gMock) allow you to ignore uninteresting actions. To do so in HMock, usesetUninterestingActionCheck
to change this severity.Ignoring uninteresting methods is non-composable. Adding an expectation for a method in one part of your test will cause the method to be considered "interesting", which can cause an unrelated part of the test to fail.
Note that
setUninterestingActionCheck Error
(the default) actually treats uninteresting methods as unexpected. If you set a weaker severity for unexpected actions, uninteresting actions will also follow that severity. -
Unexpected actions
An unexpected action occurs when an action is called that has no corresponding expectation. By default, this is an
Error
. Note that unless uninteresting actions are also set toError
, an action is only unexpected if there is at least one expectation added for the method. To change the severity of unexpected actions, usesetUnexpectedActionCheck
.This is generally intended as a temporary technique for collecting information about which expectations are needed for a test. You should be careful about leaving it this way. Prefer
allowUnexpected
if you just want to ignore some specific unexpected actions. -
Unmatched expectations
An unmatched expectation is an expectation that is not matched by any action that occurs during the test. By default, this is an
Error
. You can change this severity by callingsetUnmetExpectationCheck
.This is generally intended as a temporary technique for collecting information about which expectations are needed for a test. You should be careful about leaving it this way.
Here are a few tips for making the most of HMock.
In the most general form, an HMock rule contains a response of the form
Action ... -> MockT m r
. The action contains the parameters, and the MockT
monad can be used to add expectations or do things in the base monad. You can
build such a rule using |=>
.
However, it's very common that you don't need this flexibility, and just want
to specify the return value. In that case, you can use |->
instead to keep
things a bit more readable. m |-> r
is short for m |=> const (return r)
.
As a mnemonic device for remembering the distinction, you can think of:
|->
as ASCII art for↦
, which associates a function with a result in mathematical notation.|=>
as a relative of Haskell's>>=
, and binds an operation to a Kleisli arrow.
These three names have subtly different meanings:
foo
is the method of your own class. This is the function used in the code that you are testing.Foo
is anAction
constructor representing a call to the method. You will typically use this in three places: in an expectation when you know the exact expected arguments, as the argument tomockMethod
and friends, and as a pattern to get the parameters in a response.Foo_
is theMatcher
constructor, and expectsPredicate
s that can match the arguments in more general ways without specifying their exact values. This is more powerful, but a bit wordier, than writing an expectation using the actionFoo
. You must also useFoo_
for expectations when the method parameters lackEq
orShow
instances.
Yes!
The makeMockable
splice is the simple way to set up mocks for a class, and
delegates everything in the class to HMock to match with expectations. However,
sometimes you either can't or don't want to delegate all of your methods to
HMock. In that case, use the makeMockableWithOptions
splice, instead, and set
mockDeriveForMockT
to False
. This implements most of the deeper boilerplate
for HMock, but doesn't define the instance for MockT
. You will define that
yourself using mockMethod
and friends.
For example:
class MonadFoo m where
mockThis :: String -> m ()
butNotThis :: Int -> m String
makeMockableWithOptions [t|MonadFoo|] def { mockDeriveForMockT = False }
instance (Monad m, Typeable m) => MonadFoo (MockT m) where
mockThis x = mockMethod (MockThis x)
butNotThis _ = return "fake, not mock"
If your class has methods that HMock cannot handle, then you must do this.
These include things like associated types, methods that don't run in the monad,
or methods with non-Typeable
polymorphic return values.
HMock can be used to write mocks with polymorphic arguments, but there are a few quirks to keep in mind.
First, let's distinguish between two types of polymorphic arguments. Consider this class:
class MonadPolyArgs a m where
foo :: a -> m ()
bar :: b -> m ()
In foo
, the argument type a
is bound by the instance. Instance-bound
arguments act just like concrete types, for the most part, but check out the
later question about multi-parameter type classes for some details.
In bar
, the argument type b
is bound by the method. Because of this, the
Matcher
for bar
will be assigned the rank-n type
(forall b. Predicate b) -> Matcher ...
. In fact, pretty much the only
Predicate
you could use in such a type is anything
(which always matches, no
matter the argument value). Since eq
is not legal here, the corresponding
Action
type will not get an Expectable
instance, so you may not use it
to match an exact call to bar
.
In order to write a more specific predicate, you'd need to add constraints to
bar
in the original class. Understandably, you may be reluctant to modify
your functional code for the sake of testing, but in this case there is no
alternative. Any constraints that you add to the method can be used in
Predicate
s in the Matcher
. For example, if bar
can be modified to add a
Typeable
constraint, then you can use a predicate like typed @Int (lt 5)
,
which will only match calls where b
is Int
, and also less than 5.
Again, we can distinguish between type variables bound by the instance versus the method. Variables bound by the instance work much the same as concrete types, but check out the question about multi-parameter type classes for some details.
To mock a method with a polymorphic return value bound by the method itself, the
return value must have a Typeable
constraint. If it cannot be inferred, you
will also need to add a type annotation to the return value you set for the
method, so that GHC knows the type. If the method will be used at different
return types, you must add separate expectations to the method for each type at
which it will be used.
mockMethod
uses the Default
class from data-default
to decide what to
return when no other response is given for an expectation. If the method you
are mocking has no Default
instance for its return type, you can use
mockDefaultlessMethod
instead. In this case, if there's no response
specified, the method will return undefined
.
This choice is made automatically if you derive the instances for MockT
using
Template Haskell.
There are a few ways to do this:
- To just change the default behavior, use
byDefault
. A method call must still be expected or it will fail, but if you leave out the return value in the expectation, this default will be used. - To also make unexpected calls to a method suceed, use
allowUnexpected
. If you include a response in the argument, it will become the default in addition to allowing an unexpected method. - To set up defaults for all users of the mock, replace your call to
makeMockable
with a call tomakeMockableWithOptions
and setmockEmptySetup
toFalse
. Then write an instance forMockable
and implementsetupMockable
to do whatever you like. This setup will always run before the first time HMock touches your class from any test.
- If there is a specific method that you don't care about, use
allowUnexpected
to ignore occurrences of that method. - There's also a heuristic you can enable, where so-called "uninteresting"
methods are ignored. An uninteresting method is one for which no expectations
are added in the entire test. To allow these methods to succeed, use
setUninterestingActionCheck Ignore
orsetUninterestingActionCheck Warning
. This is similar to the behavior of gMock, which has a similar notion of uninteresting calls. - Finally, the biggest hammer is to use
setUnexpectedActionCheck Warning
. This will allow any unexpected action in your test. You almost certainly don't want to do this in your final test, but it can be useful during the course of writing the test.
By default, the most recently added expectation is matched. Think of expectations as being a stack, so they are first-in, first-matched. This rule makes HMock more compositional, since you can add and satisfy expectations in a part of your test without worrying that expectations from a larger containing block will interfere.
There is also an option to fail when more than one expectation matches. To
enable this, just include setAmiguityCheck True
as a statement in MockT
.
From that point forward, ambiguous matches will throw errors.
In order to mock a multi-parameter type class, the monad argument m
must be
the last type variable. Then just use makeMockable [t|MonadMPTC|]
.
We will consider classes of the form
class MonadMPTC a b c m | m -> a b c
If you try to use makeMockable [t|MonadMPTC|]
, it will fail. The functional
dependency requires that a
, b
, and c
are determined by m
, but we cannot
determine them for the MockT
instance.
The recommended way to handle this case is to pass a concrete type to
makeMockable
, like this:
makeMockable [t| MonadMPTC Int String Int |]
This will define the same Mockable
instance for MonadMPTC
, but the instance
for MockT
will be defined with concrete types as required by the functional
dependency.
Note that the MockT
instance is anti-modular, because you cannot import (even
indirectly) two different instances for MockT
with different types in the same
module. These instances would be incoherent, which Haskell doesn't typically
allow. You can minimize the risk by using makeMockable
in top-level test
modules which are never imported elsewhere. If you want to share the
expectation code, you can use makeMockableWithOptions
and set
mockDeriveForMockT
to False
in your library code, and then use
makeMockable
again in your top-level test module to define the MockT
instance.
If you absolutely need to write multiple tests in the same module with different
type parameters, you will need to use a wrapper around the base monad for
MockT
to disambiguate the instances. That is a bit more involved. Here's an
example:
makeMockableWithOptions [t|MonadMPTC|] def {mockDeriveForMockT = False}
newtype MyBase m a = MyBase {runMyBase :: m a}
deriving (Functor, Applicative, Monad)
instance
(Monad m, Typeable m) =>
MonadMPTC String Int String (MockT (MyBase m))
where
foo x = mockMethod (Foo x)
If your code uses MonadUnliftIO
to create threads, you can test it directly
with HMock. Otherwise, you can use withMockT
to manually inject each of your
threads into the same MockT
block. Whichever way you do it, the expectations
are shared between threads so that an expectation added in one thread can be
fulfilled by the other.
If you don't want to share expectations, then you can use runMockT
once per
thread to run each thread with its own set of expectations.
You can use either the exceptions
or unliftio
packages to throw and catch
exceptions from code tested with MockT
.
The behavior of HMock
is unspecified if you continue testing after throwing
asynchronous exceptions to your threads using throwTo
. This concern doesn't
apply to synchronous exceptions thrown with throwIO
or throwM
.
HMock is compatible with stack traces using HasCallStack
. These can be very
convenient for finding out where your code went wrong. However, the stack
traces are useless unless you add a HasCallStack
constraint to the methods of
your class. This is unfortunate, but not really avoidable with the current
state of Haskell. You can add the constraint when troubleshooting, and remove
it again when you are done.
If you have the warning enabled, GHC will usually warn about orphan instances
when you use makeMockable
. We recommend disabling this warning for the
modules that use makeMockable
, by adding the line
{-# OPTIONS_GHC -Wno-orphans #-}
to the top of these modules.
Prohibiting orphan instances is just a heuristic to make it less likely that
two different instances will be defined for the same type class and parameters.
The heuristic works well for most application code. It does not work so
well for HMock, because the class you are mocking is non-test code, but the
MockableBase
, Mockable
, and MockT
instances should be defined in test
code.
Since the orphan heuristic doesn't work, you must take responsibility for managing the risk of multiple instances. The easiest way to do so is to avoid defining these instances in libraries. If you do define instances in libraries, you must choose a canonical location for each instance that is consistent across all code using the library.
When adding an expectation, you can only use an Action
if the method is simple
enough. Specifically, all arguments must have Eq
and Show
instances, and no
arguments may rely on type variables bound by the method.
If your method isn't simple enough, the solution is to add the expectation with
a Matcher
instead of an Action
. The arguments to Matcher
are Predicate
s
that can inspect the value and decide whether to match. You are now responsible
for deciding how to match the complex argument. Some options include:
- Using a polymorphic
Predicate
likeanything
. - Ensuring that a
Typeable
constraint is available, and using thetyped
predicate to cast the argument to a known type. - Using the
is
orwith
Predicate
s and your own code that's polymorphic in the type and produces a monomorphic result type you can match on.
To mock a class with the monad-mock
package, you will have used that library's
Template Haskell splice called makeAction
. With HMock, you should use
makeMockable
instead. Unlike makeAction
, you will use makeMockable
separately for each class you intend to mock. The generated code is still
usable with any combination of other classes in the same tests.
Where you may have previously written:
makeAction ''MyAction [ts| MonadFilesystem, MonadDB |]
You will now write:
makeMockable [t|MonadFilesystem|]
makeMockable [t|MonadDB|]
To convert a test that uses monad-mock
into a test using HMock, move
expectations from the list argument of runMockT
into expect
calls inside
HMock's runMockT
. To preserve the exact behavior of the old test, wrap your
expect
s with inSequence
. You'll also need to switch from monad-mock
's
:->
to HMock's |->
, which means the same thing.
If you previously wrote (with monad-mock):
runMockT
[ ReadFile "foo.txt" :-> "contents",
WriteFile "bar.txt" "contents" :-> ()
]
(copyFile "foo.txt" "bar.txt")
You will now write this (with HMock):
runMockT $ do
inSequence
[ expect $ ReadFile "foo.txt" |-> "contents",
expect $ WriteFile "bar.txt" "contents" |-> ()
]
copyFile "foo.txt" "bar.txt"
Now that your test has been migrated without changing its behavior, you may
begin to remove assertions that monad-mock
forced you to write even though you
didn't intend to test them. For example:
-
You don't really care about the return value for
writeFile
. HMock will return a default value for you if you leave out the|->
operator. -
inSequence
is overkill here, since the sequence is just a consequence of data dependencies. (Think of it this way: if it were magically possible forwriteFile
to be called with the right arguments but without waiting on thereadFile
, it would be correct to do so! The order is a consequence of the implementation, not the specification.)
Applying these two simplifications, you have a final test:
runMockT $ do
expect $ ReadFile "foo.txt" |-> "contents"
expect $ WriteFile "bar.txt" "contents"
copyFile "foo.txt" "bar.txt"
HMock is tested with GHC versions from 8.6 through 9.10.
As a non-trivial case study in the use of HMock, consider the problem of testing
code that uses Template Haskell. Template Haskell runs in a monad class called
Quasi
, which provides access to actions that help build code: generating fresh
names, looking up type information, reporting errors and warnings, and so on.
While there is an IO
instance for the Quasi
type class, it throws errors for
most operations, making it unsuitable for testing any non-trivial uses of
Template Haskell.
As part of HMock's own test suite, the Quasi
monad is made mockable (in
test/QuasiMock.hs
), and then used (in test/Classes.hs
) to test HMock's
Template Haskell-based code generation.
At first glance, this might seem unnecessary. After all, the unit tests make
use of makeMockable
for tests of the core HMock functionality, so surely any
problems in that code that matter would cause one of the core tests to fail, as
well. However, writing these tests with mocks had two significant benefits:
-
Because Template Haskell runs at compile time, test coverage cannot be measured. Template Haskell code at runtime generates accurate test coverage using
hpc
. This, in turn, helped with writing more comprehensive tests. -
Because Template Haskell errors would stop the tests from compiling, core tests can only cover successful uses. Mocking
Quasi
allows tests of Template Haskell to check the error cases, as well.
Indeed, mock Quasi
tests were quite valuable to HMock development. First, the
initial tests revealed several places where tests did not exercise key logic:
mocking classes with superclasses, and mocking classes whose methods have rank-n
parameters. When corresponding tests were added, both of these cases turned out
to be incorrect! Next, adding tests for the error cases (which would not have
been possible to write without mocks) revealed that the code to detect mocking
classes with too many arguments was also broken, so that instead of a nice
helpful message, HMock printed something about an internal error, advising the
user to report a bug.
The implementation of the Quasi
mock was not difficult, but there are a few
places where it was illuminating:
-
Several methods of the
Quasi
type class could not be mocked by HMock because they have polymorphic return types withoutTypeable
constraints. This did not prevent using HMock, but it did make it necessary to use a hand-writtenMockT
instance, rather than usingmakeMockable
to generate everything. -
One method,
qNewName
, could have been mocked, but it wasn't the right choice to do so. Template Haskell already provides an implementation in theIO
monad, which was already suitable for testing. This wasn't a problem, as theMockT
instance could be written to forward to theIO
implementation. -
Certain behaviors that did need to be mocked, such as looking up
Eq
andShow
instances for common types, were useful for many different tests. To help with reuse, these actions are added fromsetupMockable
so they are automatically mocked every time the class is used. -
The mocks and setup code required less than 50 lines of very straight-forward code. There was, though, a need to write more code to derive
Lift
andNFData
instances for Template Haskell classes so that the correct behavior of the mock could be implemented and tested.
All things considered, the mock of Quasi was not difficult to implement, and improved both the experience of HMock development and confidence in its correctness.