Mocking Effects using Constraints and Phantom Data Kinds


Sep 29, 2018

This ended up being a pretty long post; if you're pretty comfortable with monad constraints and testing in Haskell you may want to jump down to the Phantom Data Kinds section and get to the interesting stuff!

Refresher on granular classes

I've been seeing a lot of talk on the Haskell subreddit about how to properly test Haskell applications; particular how to test actions which require effects. I've seen a lot of confusion/concern about writing your own monad transformers in tests. This post is my attempt to clear up some confusion and misconceptions and show off my particular ideas for testing using mtl-style constraints. The ideas contained here can also help you with writing multiple 'interpreters' for your monad stacks without needing a newtype for each permutation of possible implementations.

First things first, what do I mean by mtl-style constraints? I'd recommend you consult MonadIO Considered Harmful, it's a post I wrote on the topic almost exactly a year ago. Here's the spark-notes version:

Okay, so assuming we're all on board with writing our code polymorphically using Monad Constraints, what's the problem? Well, the reason we're doing it polymorphically is so we can specialize the monad to different implementations if we want! This is one way to implement the dependency injection pattern in Haskell; and lets us substitute out the implementation of our monadic effects with 'dummy' or 'mock' versions in tests.

The trick is that we run into a lot of annoying repetition and boiler-plate which gets out of control as we scale up the number of effects we use. To show the problem let's assume we have some action that does something, and needs the following three constraints which you can assume are type-classes we've defined using the 'granular mtl' style:

Now, assume we've already implemented MonadFileSystem, MonadDB, and MonadLogger for our application's main monad, but when we test it we probably don't want to hit our real DB or file-system so we should probably mock those out. We'll need a new monad type to implement instances against:

I'm not getting into many details yet and have elided the implementations here for brevity, but hopefully that shows how you could implement those interfaces in terms of some pure monad stack like State in order to more easily write tests. BUT! What if for a new test we want the file-system to behave differently and fail on every request to read a file? We could add a boolean into the state that dictates this behaviour, but that will definitely complicate the implementation of our instance, we could add a newtype wrapper which has a different MonadFileSystem instance, but we'd need to regain all our instances for the other type-classes again! We can use GeneralizedNewtypeDeriving to help, but say we now want multiple behaviours for our MonadDB instance! Things get out of control really quickly, this post investigates a (slightly) cleaner way to go about this.

Our goals are as follows:

That's a tall order! Let's dig in and see if we can manage it!

Case Study

This topic is tough to explain without concrete examples, so bear with me while we set some things up. Let's start by looking at how someone may have written a really simple app and some functions for working with their database.

Here's our base monad type:

We've abstracted over our database actions already with the following type-class:

Cool! This looks pretty normal, we have a primary app monad and we can get and store strings in our database via IO using it!

Now that we've got our basic DB interface let's say we want to write a more complex action using it:

It's a pretty simple action, but we should probably add some unit tests! Let's set it up using our AppM instance!

Well, this should work, but we're using IO directly in tests; not only is this going to be slow; but it means we need to have a database running somewhere, and that the tests might pass or fail depending on the initial state of that database! Clearly that's not ideal! We really only want to test the semantics of upperCase and how it glues together the interface of our database; we don't really care which database it's operating over.

Our uppercase action is polymorphic over the monad it uses, so that means we can write a new instance for MonadDB and get it to use that in the tests instead!

Now we have a completely pure way of modeling our DB, which we can seed with initial data, and we can even inspect the final state if we like! This makes writing tests so much easier. We can re-write the upperCase test using State instead of IO! This means we have fewer dependencies, fewer unknowns, and can more directly test the behaviour of the action which we actually care about.

Here's the re-written spec, the test itself is the same, but we no longer run it in IO:

Nifty!

Parameterizing test implementations

The thing about tests is that you often want to test unique and interesting behaviour! This means we'll probably want multiple implementations of our mocked services which each behave differently. Let's say that we want to test what happens if our DB fails on every single call? We could implement a whole new TestM monad with a new instance for MonadDB which errors on every call, and this would work fine, but in the real world we'll probably be mocking out a half-dozen services or more! That means we'll need a half dozen instances for each and every TestM we build! I don't feel like working overtime, so let's see if we can knock down the boilerplate by an order of magnitude. It's getting tough to talk about this abstractly so let's expand our example to include at least one other mocked service. We'll add some capability to our AppM to handle input and output from the console!

Now we can get something from the user and store it in the DB!

Let's jump into testing it! To do so we'll need to make TestM an instance of MonadCli too! Now that we have multiple concerns going on I'm going to use a shared state and add some lenses to make working with everything a bit easier. It's a bit of set-up up-front, but from now on adding additional functionality should be pretty straight-forward!

Now we can test our storeName function!

Hopefully that comes out green!

Great! So, like I said before we'd like to customize our implementations of some of our mocked out services, let's say we want the DB to fail on every call! One option would be to wrap TestM in a newtype and use deriving MonadCli with GeneralizedNewtypeDeriving to get back our implementation of MonadCli then write a NEW instance for MonadDB which fails on every call. If we have to do this for every customized behaviour for each of our services though this results in an (n*k) number of newtypes! We need a different newtype for EACH pairing of every possible set of behaviours we can imagine! Let's solve this problem the way we solve all problems in Haskell: Add more type parameters!

Phantom Data Kinds

Let's parameterize TestM with slots which represent possible implementations of each service. To help users know how it works and also prevent incorrect usage we'll qualify the parameters using DataKinds!

Notice that we don't actually need to use these params inside the definition of the TestM newtype; they're just there as annotations for the compiler, I call these πŸ‘» Phantom Data Kinds πŸ‘». Now let's update the instance we've defined already to handle the type params, as well as add some new instances!

The instance signatures are the most important part here; but read the rest if you like πŸ€·β€β™‚οΈ

The cool thing about this is that each instance can choose an instance based on one parameter while leaving the type variable in the other slots unspecified! So for our MonadDB implementation we can have a different implementation for each value of the db :: DBImpl type param while not caring at all what's in the cli :: CliImpl parameter! This means that we only need to implement each behaviour once, and we can mix and match implementations for different services at will! We do need to make sure that there's some way to actually implement that behaviour against our TestM; but for the vast majority of cases you can just carve out a spot in the State to keep track of what you need for your mock. Using lenses means that adding something new won't affect existing implementations.

Whoops; just about forgot, we need a way to pick which behaviour we want when we're actually running our tests! TypeApplications are a huge help here! We use TypeApplications to pick which DBImpl and CliImpl we want so that GHC doesn't get mad at us about ambiguous type variables. Use them like this:

Now you can pretty easily keep a separate module where you define TestM and all of its behaviours and instances, then just use Type Applications to specialize your test monad when you run it to get the behaviour you want!

And that wraps up our dive into testing using mtl-style constraints! Thanks for joining me!

Special thanks to Sandy Maguire A.K.A. isovector for proofreading and helping me vet my ideas!

If you have questions or comments hit me up on Twitter or Reddit!