Ad-hoc Test Spies can be implemented in Haskell using the Writer monad.

In a previous article on state-based testing in Haskell, I made the following throw-away statement:

"you could write an ad-hoc Mock using, for example, the Writer monad"
In that article, I didn't pursue that thought, since the theme was another. Instead, I'll expand on it here.

Test Double continuum #

More than a decade ago, I wrote an MSDN Magazine article called Exploring The Continuum Of Test Doubles. It was in the September 2007 issue, and you can also download the entire issue as a single file and read the article offline, should you want to.

In the article, I made the argument that the classification of Test Doubles presented in the excellent xUnit Test Patterns should be thought of more as a continuum with vague and fuzzy transitions, rather than discrete categories.

Spectrum of Test Doubles.

This figure appeared in the original article. Given that the entire MSDN Magazine issue is available for free, and that I'm the original author of the article, I consider it fair use to repeat it here.

The point is that it's not always clear whether a Test Double is, say, a Mock, or a Spy. What I'll show you in this article is closer to a Test Spy than to a Mock, but since the distinction is blurred anyway, I think that I can get away with it.

Test Spy #

xUnit Test Patterns defines a Test Spy as a Test Double that captures "the indirect output calls made to another component by the SUT [System Under Test] for later verification by the test." When, as shown in a previous article, you use Mock<T>.Verify to assert than an interaction took place, you're using the Test Double more as a Spy than a Mock:

repoTD.Verify(r => r.Update(user));

Strictly speaking, a Mock is a Test Double that immediately fails the test if any unexpected interaction takes place. People often call those Strict Mocks, but according to the book, that's a Mock. If the Test Double only records what happens, so that you can later query it to verify whether some interaction took place, it's closer to being a Test Spy.

Whether you call it a Mock or a Spy, you can implement verification similar to the above Verify method in functional programming using the Writer monad.

Writer-based Spy #

I'll show you a single example in Haskell. In a previous article, you saw a simplified function to implement a restaurant reservation feature, repeated here for your convenience:

tryAccept :: Int -> Reservation -> MaybeT ReservationsProgram Int
tryAccept capacity reservation = do
  guard =<< isReservationInFuture reservation
  reservations <- readReservations $ reservationDate reservation
  let reservedSeats = sum $ reservationQuantity <$> reservations
  guard $ reservedSeats + reservationQuantity reservation <= capacity
  create $ reservation { reservationIsAccepted = True }

This function runs in the MaybeT monad, so the two guard functions could easily prevent if from running 'to completion'. In the happy path, though, execution should reach 'the end' of the function and call the create function.

In order to test this happy path, you'll need to not only run a test-specific interpreter over the ReservationsProgram free monad, you should also verify that reservationIsAccepted is True.

You can do this using the Writer monad to implement a Test Spy:

testProperty "tryAccept, happy path" $ \
  (NonNegative i)
  (fmap getReservation -> reservations)
  (ArbReservation reservation)
  let spy (IsReservationInFuture _ next) = return $ next True
      spy (ReadReservations _ next) = return $ next reservations
      spy (Create r next) = tell [r] >> return (next expected)
      reservedSeats = sum $ reservationQuantity <$> reservations
      capacity = reservedSeats + reservationQuantity reservation + i
      (actual, observedReservations) =
        runWriter $ foldFreeT spy $ runMaybeT $ tryAccept capacity reservation
  in  Just expected == actual &&
      [True] == (reservationIsAccepted <$> observedReservations)

This test is an inlined QuickCheck-based property. The entire source code is available on GitHub.

Notice the spy function. As the name implies, it's the Test Spy for the test. Its full type is:

spy :: Monad m => ReservationsInstruction a -> WriterT [Reservation] m a

This is a function that, for a given ReservationsInstruction value returns a WriterT value where the type of data being written is [Reservation]. The function only writes to the writer context in one of the three cases: the Create case. The Create case carries with it a Reservation value here named r. Before returning the next step in interpreting the free monad, the spy function calls tell, thereby writing a singleton list of [r] to the writer context.

In the Act phase of the test, it calls the tryAccept function and proceeds to interpret the result, which is a MaybeT ReservationsProgram Int value. Calling runMaybeT produces a ReservationsProgram (Maybe Int), which you can then interpret with foldFreeT spy. This returns a Writer [Reservation] (Maybe Int), which you can finally run with runWriter to get a (Maybe Int, [Reservation]) tuple. Thus, actual is a Maybe Int value, and observedReservations is a [Reservation] value - the reservation that was written by spy using tell.

The Assert phase of the test is a Boolean expression that checks that actual is as expected, and that reservationIsAccepted of the observed reservation is True.

It takes a little while to make the pieces of the puzzle fit, but it's basically just standard Haskell library functions clicked together.

Summary #

People sometimes ask me: How do Mocks and Stubs work in functional programming?

In general, my answer is that you don't need Mocks and Stubs because when functions are pure, you don't need to to test interactions. Sooner or later, though, you may run into higher-level interactions, even if they're pure interactions, and you'll likely want to unit test those.

In a previous article you saw how to apply state-based testing in Haskell, using the State monad. In this article you've seen how you can create ad-hoc Mocks or Spies with the Writer monad. No auto-magical test-specific 'isolation frameworks' are required.

Wish to comment?

You can add a comment to this post by sending me a pull request. Alternatively, you can discuss this post on Twitter or somewhere else with a permalink. Ping me with the link, and I may respond.


Monday, 08 April 2019 06:02:00 UTC


"Our team wholeheartedly endorses Mark. His expert service provides tremendous value."
Hire me!
Published: Monday, 08 April 2019 06:02:00 UTC