This short post is an answer to the following question asked on r/haskell. The original question is about how to test code that lives in a Monad class with polymorphic functions.

I highly encourage you to read the post. Its different answers are full of technical gems which we are not going to explore here in this post. Instead, our goal will be to provide a simpler solution to the problem, by just playing and reworking some abstractions.

### The original problem

The OP came with the following need. She needs to model some notion of secure token management. The real implementation of the secure token management makes use of complex encryption and decryption algorithms, which she would like to abstract away from her unit tests.

###### Monad type class

To abstract away the details of the encryption and decryption, and make it possible to test her code without having to deal with their real implementation, the OP introduced the following MTL-like type-class:

**encryptToken**maps a polymorphic token to a string**decryptToken**maps back a string to a polymorphic token- And a
**Token**is something that can be serialized to and from JSON

class Monad m => MonadToken m where | |

encryptToken :: Token t => t -> m String | |

decryptToken :: Token t => String -> m (Maybe t) | |

class FromJSON a where | |

fromJson :: JSON -> a | |

class ToJSON a where | |

toJson :: a -> JSON | |

class (FromJSON t, ToJSON t) => Token t |

Let us see of example of use of this Monad. The following code encrypts a token to decrypt it immediately after and return the result of the operation (agreed, it is not really useful, except maybe for some property based testing needs):

backAndForth :: (MonadToken m, Token t) => t -> m (Maybe t) | |

backAndForth t = do | |

s <- encryptToken t | |

decryptToken s |

The whole approach described by the OP follows a typical Hexagonal-like Architecture, which is interesting for it decouples the code from the implementation of the services it relies upon (the encryption and decryption of tokens), allowing us to test it more easily.

###### Testing polymorphic interfaces

The problem with the approach above, pointed by the OP, is that testing code that makes use of the *MonadToken* typeclass gets pretty complex due to the polymorphic functions.

A typical fake implementation would use a Map to associate tokens with their corresponding encrypted string (and vice-versa for the decryption). The problem is that building a map with polymorphic keys in Haskell is not an easy task.

There are ways to do this, and you can check some of the great answers available in r/haskell. They make use of advanced and pretty interesting features of Haskell (such as Data.Typeable or Data.Constraint). We will instead explore a simpler solution.

### Another take at the problem

Let us try a simpler solution, that does not require using any advanced Haskell features, but instead relies on rethinking the design just a little bit.

###### Looking at the types

Let us start from our use case. We are really interested in testing some code that makes use of the encryption and decryption of token, such as this code:

backAndForth :: (MonadToken m, Token t) => t -> m (Maybe t) | |

backAndForth t = do | |

s <- encryptToken t | |

decryptToken s |

This code makes use of the *encryptToken* and *decryptToken* polymorphic functions, whose type are given below (using **:type** in the REPL):

encryptToken :: (Token t, MonadToken m) => t -> m String | |

decryptToken :: (Token t, MonadToken m) => String -> m (Maybe t) |

Now, the fact that our code makes use of these function does not imply in any way that these functions must be part of the *MonadToken* interface. Instead, these functions could be based on lower-level function available in the *MonadToken* interface.

This is the option we will be exploring below.

###### Another Monad interface

We can transform slightly our Monad interface by realizing that the only thing that we know form the token given to the *encryptToken* and *decryptToken* polymorphic functions is that:

- Our token must be serializable to JSON
- Our token must be de-serializable from JSON

So there is no loss in generality in transforming our interface *MonadToken* into the following *MonadCypher* interface, which deals with concrete JSONs instead of polymorphic tokens:

class Monad m => MonadCypher m where | |

encryptJson :: JSON -> m String | |

decryptJson :: String -> m (Maybe JSON) |

To keep our original code working, we can then build our *encryptToken* and *decryptToken* polymorphic function on top this *MonadCypher* type class:

encryptToken :: (Token t, MonadCypher m) => t -> m String | |

encryptToken = encryptJson . toJson | |

decryptToken :: (Token t, MonadCypher m) => String -> m (Maybe t) | |

decryptToken str = (fmap . fmap) fromJson (decryptJson str) |

In fact, we can simplify further these two functions. We can generalize them by realizing that *encryptToken* only needs the *ToJSON* constraint, and *decryptToken* only needs the *FromJSON* constraint. I linked the implementation here.

Now, let us see now how this design helps with testing.

###### Testing with a Reader Monad

The key aspect in our new design is that our *MonadCypher* does not rely on polymorphic tokens anymore, but instead relies on concrete JSON values. It makes testing much easier.

To define a fake interface, we can start by defining a *Cyphers* data type that contains the necessary maps to associate JSON values to encrypted values, and vice-versa:

data Cyphers = Cyphers { | |

encryptMap :: M.Map JSON String, | |

decryptMap :: M.Map String JSON | |

} deriving (Show, Eq, Ord) |

From there, you know the drill. We simply wrap this data type inside a *FakeCypher* type, and implements the necessary Monad type class instances:

— Wrapping a Reader Monad transformer (test environment) | |

newtype FakeCypher m a = FakeCypherT (ReaderT Cyphers m a) | |

deriving (Functor, Applicative, Monad, MonadTrans) | |

— MonadCypher instance for our test environment | |

instance Monad m => MonadCypher (FakeCypher m) where | |

encryptJson json = do | |

cyphers <- FakeCypherT ask | |

pure (encryptMap cyphers M.! json) | |

decryptJson str = do | |

cyphers <- FakeCypherT ask | |

pure (M.lookup str (decryptMap cyphers)) | |

— To run a `MonadCypher` computation in our test environment | |

runFakeCypher :: (Monad m) => FakeCypher m a -> Cyphers -> m a | |

runFakeCypher (FakeCypherT f) r = runReaderT f r |

Here we are. We managed to make our code testable. And we also avoided to rely on advanced features such as Data.Typeable or Data.Constraint to do so.

###### Additional benefits & Going further

In additional of being more easily testable, our *MonadCypher* interface also has additional benefits in comparison to the previous *MonadToken* interface.

A first advantage we get is that we are able to test more things. The faking of the encryption does not involve the faking of the serialization to JSON. A second advantage is that this design imposes less constraints on the implementation: encrypting does not require anymore the *FromJSON* constraint.

We could go a bit further and try to get to the essence of encryption and decryption, by relaxing some more constraints and being more generic. For instance, we could try to define our *MonadCypher* so that it is not defined in terms of JSON (and rather use Data.ByteString for instance).

### Conclusion

There is a great deal of freedom in the way we can define type-classes or interfaces. Following the precepts of programming against abstraction helps us make our test more testable, but some abstractions are easier to test against than other.

Depending on the host language, and as shown by the OP of the initial r/haskell post, the choice of the mean of abstraction (the notion being abstracted being the same) leads to different level of sophistication in order to implement it or fake it.

Ultimately though, we can often drastically reduce the complexity of a solution by reworking it even just slightly, typically to avoid falling into the dark corners of the language we use.

Follow @quduval

## Leave a Reply