Jezen Thomas

Jezen Thomas

CTO & Co-Founder at Supercede.

Test Spies in Haskell

When testing a web application, you often want to make sure that a certain email would be sent — without actually sending it. How do you test that?

Take something like a transactional email: a user signs up, resets their password, or completes a purchase — and your application needs to send a notification. You don’t care how the email gets sent — just that it was triggered correctly.

The simplest implementation for the email-sending function might look like this.

sendEmail :: Recipient -> IO ()
sendEmail recipient =
  -- some email sending code here…

All this function needs to know is where to send the email. How it sends it isn’t important here — and it’s not what we’re testing. What we care about is how it’s called.

This function might be called from a request handler, and it would be that handler function’s responsibility to decide how to call the email sending function.

postThingR :: Handler ()
postThingR = do
  liftIO $ sendEmail "user@example.com"

We can track how a function is called with a test spy.

Spies are stubs that also record some information based on how they were called.

You can emulate a test spy using the same stubbing method I wrote about earlier. Instead of calling sendEmail directly, you’ll retrieve it from somewhere that can be swapped out at runtime — like your application’s settings.

postThingR :: Handler ()
postThingR = do
  sendEmail <- getsYesod appSendEmail
  liftIO $ sendEmail "user@example.com"

The idea is to swap the implementation for one that records the arguments that the function was called with, and then check what was recorded in the test’s assertion phase. We can use mutable state — such as an IORef or TVar — to record how the function was called. Here, we’ll keep it simple with IORef.

it "notifies the right person" $ do

  -- Arrange
  callsRef <- liftIO $ newIORef []
  stub $ \app -> app {
    appSendEmail = \recipient ->
      liftIO $ modifyIORef' callsRef $ \cs -> recipient : cs
    }

  -- Act
  post ThingR

  -- Assert
  liftIO $ do
    calls <- readIORef callsRef
    calls `shouldBe` ["user@example.com"]

This test swaps in a fake email function, triggers the handler, and checks that the expected recipient was recorded.

With this pattern, you can test side effects without relying on real services — making your tests fast, isolated, and reliable.