Tagless Final Encoding of a Test Language
I have experimented with a test language encoded in tagless final style, instead of algebraic data types, to support the typed combinators beforeEach and beforeAll. Although the intended use is for PureScript Spec, I want to share the Haskell prototype I ended up with, and explain how I got there.
The Algebraic Data Type Approach
The PureScript Spec project, inspired by Haskell’s hspec, provides an EDSL and framework for writing, organizing, and running PureScript tests. Combinators use a State monad collecting tests into an algebraic data structure, representing the test language tree structure.
"My Module" $ do
describe "Feature #1" $ do
describe "does addition" (1 + 1 `shouldEqual` 2)
it "does subtraction" (1 - 1 `shouldEqual` 0)
it "Feature #2"
describe "does multiplication" (2 * 2 `shouldEqual` 4) it
The Group
data type holds describe groups and it tests, here shown in a simplified form, and translated to Haskell. The test body has the parameterized type t
, making the Group
structure suitable for representing not only tests to be run, but also for test results.
data Group t
= Describe String [Group t]
| It String t
A test suite to be run can have type [Group (IO ())]
, and a test result can have type [Group TestResult]
.
In a GitHub pull request for PureScript Spec, we discussed how to implement setup and tear-down functions around tests, and how to make them type safe. I started poking around in the code base, but soon realized that the required change was larger than I first imagined, and so I began on a clean slate prototype. The reason I used Haskell was to focus more on modeling different alternatives, and less time on hand-written instances for newtypes.
I wanted a setup function to provide a return value, and all tests run with the setup to receive the return value as a parameter. Thus, n setup functions would require test functions of n arguments. A test with an incorrect number of arguments would give a type error at compile-time.
My first attempt was to extend the current design by adding a new constructor BeforeEach
to the Group
data type. Using the already parameterized test body, tests inside a BeforeEach
would be functions from the return value of type b
, to some test body of type t
. For each nesting of BeforeEach
, test body types would get an additional argument.
data Group b t
= Describe String [Group b t]
| It String t
| BeforeEach (IO b) (Group b (b -> t))
While this structure can hold multiple nested BeforeEach
values, and enforce the correct number of arguments to It
body functions, the type b
cannot vary throughout the structure. Requiring all setup functions in a test suite to return values of the same type was not acceptable. I suspect that there might be a way to solve this in Haskell using GADTs and existential types, but I’m not sure how it would translate to PureScript.
Following the idea of parameterizing Group
further, I’d probably end up close to a specialized version of the Free monad. Why free monads matter explains a similar path, arriving at the Free monad, most eloquently. I decided, however, to try out a tagless final style encoding for the test language in my Haskell prototype.
Exploring Tagless Final Encoding
Having kept an eye out for practical examples of tagless final style, I was keen on trying it out for the test language. The discussion started on a local meetup in Malmö, where I presented the problem, together with my suspicion that a combination of tagless final style encoding and Applicative would solve it elegantly. The following design is the result of my own explorations after the meetup, and will hopefully be of use for the real implementation in PureScript Spec in the future.
As this article is a runnable program in Literate Haskell form, we begin by declaring a bunch of language extensions, the module, and our imports.
{-# LANGUAGE DeriveFunctor #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE MultiParamTypeClasses #-}
module Test.Spec where
import Control.Monad.Identity
import Control.Monad.IO.Class
import Control.Monad.Writer
import Data.Function
import Data.List
import System.IO.Memoize
Encoding the test language in tagless final style means that all operations are overloaded functions in a type class. MonadSpec
takes two type arguments; m
and f
, constrained to Monad
and Applicative
, respectively. The class includes the operations it
, describe
, beforeEach
, and beforeAll
.
class (Monad m, Applicative f) => MonadSpec m f where
The operations of the type class constitute the whole language. Beginning with the leaf operation it
, we see that it takes a string description, some test of type a
, and returns a Spec
parameterized by m
and (f a)
.
it :: String -> a -> Spec m (f a)
Having the test, a value of type a
, wrapped up inside the (Applicative f)
is essential for our operations to compose.
The describe
operation takes a string describing a group of tests, and another Spec, with any type of tests, as long as they are inside the (Applicative f)
.
describe :: String -> Spec m (f a) -> Spec m (f a)
The setup combinators beforeEach
and beforeAll
have identical type signatures. They take a setup value of type (f a)
, and a Spec with tests of type (f (a -> b))
, returning a Spec with tests of type (f b)
. The type shows that the applicative test functions are applied by the setup combinators.
beforeEach :: f a -> Spec m (f (a -> b)) -> Spec m (f b)
beforeAll :: f a -> Spec m (f (a -> b)) -> Spec m (f b)
What is a Spec
? A Writer monad transformer, collecting Group
values. Using an explicit WriterT
is needed for the interpreter, explained shortly, to capture nested tests, apply test functions to setup combinators’ return values, and change the type of the test structure during interpretation.
type Spec m a = WriterT [Group a] m ()
The Collector
interpreter collects a Spec into a data structure, much like the original approach, but with all test functions fully applied inside the applicative.
newtype Collector m a = Collector { unCollector :: m a }
deriving ( Functor
Applicative
, Monad
, MonadIO
, )
The Group
data structure holds the applied test functions, and thus has no constructors for BeforeEach
and BeforeAll
.
data Group a
= Describe String [Group a]
| It String a
deriving (Functor)
In effect, the Collector
interpreter loses information when collecting the test suite as a Group
data structure. Other interpreters, e.g. a test suite pretty-printer, would provide its own instance of the beforeEach
and beforeAll
operations, and retain the setup information for its particular purpose.
The MonadSpec
instance for the Collector
interpreter defines the applicative type parameter as IO
. This means that all setup combinator values must have type (IO a)
, where the nested test functions have type (IO (a -> b))
.
instance (Monad m, MonadIO m)
=> MonadSpec (Collector m) IO where
The it
instance wraps the test body inside the applicative, and reports the Group
in the WriterT
monad.
=
it name body It name (return body)] tell [
To wrap test groups in a Describe
value, the instance of describe
runs the nested WriterT
to collect all groups.
= do
describe name spec <- lift $ execWriterT spec
groups Describe name groups] tell [
The interesting part is the beforeEach
instance, where the inner test function is applied using (<*>)
and (&)
. As the Group
data type has an instance of Functor
, the application can be mapped recursively over the structure using fmap
.
= do
beforeEach setup spec <- lift $ execWriterT spec
groups $ fmap ((&) <$> setup <*>) <$> groups tell
This is where WriterT
must be explicit in the MonadSpec
operations. In a previous attempt, I had a MonadWriter
constraint on the interpreter, and no mention of WriterT
in MonadSpec
. That approach prohibited the monoidal type of MonadWriter
to change during interpretation, a change required to apply test functions when interpreting setup combinators. Instead, by making WriterT
explicit in the MonadSpec
operations, the Collector
instance can collect groups using lift
and execWriterT
, and freely change the test function types.
As a slight variation on beforeEach
, the beforeAll
combinator must only run the setup action once, applying all test functions with the same return value. Using the io-memoize package, and the existing beforeEach
combinator, we can do this neat trick:
= do
beforeAll setup spec <- liftIO $ once setup
s beforeEach s spec
Collecting all tests, fully applied with setup return values, is a matter of running the WriterT
and Collector
instances, and joining the applicative values with the test body values, here constrained to be the same monadic type m
. Note that Applicative is a super class of Monad since GHC 7.10.
collect :: Monad m
=> Spec (Collector m) (m (m a))
-> m [Group (m a)]
= do
collect spec <- unCollector $ execWriterT spec
groups return (map (fmap join) groups)
Using collect
, the run
function traverses the group of tests, printing and running all tests, in the (MonadIO m)
instance.
run :: MonadIO m => Spec (Collector m) (m (m ())) -> m ()
= do
run spec <- collect spec
groups mapM_ (go []) groups
where
Describe name groups) =
go ctx (mapM_ (go (ctx ++ [name])) groups
It name body) = do
go ctx (let heading = intercalate " > " ctx ++ " > it " ++ name
$ putStrLn heading
liftIO body
We can now nest setup combinators, describe groupings, and tests, with different types, while retaining type safety. The test suite type signature is somewhat verbose, but that could be hidden with a type alias. The only drawback, as I see it, is that the outer setup combinators provide the right-most test function parameters, which feels a bit backwards from a user point of view.
mySpec :: MonadSpec m IO => Spec m (IO (IO ()))
=
mySpec putStrLn "before all!" >> return "foo") $ do
beforeAll (
"module 1" $
describe putStrLn "before each 1!" >> return 20) $
beforeEach ("feature A" $ do
describe "works!" (\x y -> assert (x == 20))
it "works again!" (\x y -> assert (y == "foo"))
it
"module 2" $
describe putStrLn "before each 2!" >> return 30) $
beforeEach ("feature B" $ do
describe "works!" (\x y -> assert (x == 30))
it "works again!" (\x y -> assert (y == "foo"))
it where
True = return ()
assert False = fail "Test failure!" assert
Using IO
and fail
to report test failures would be less then ideal a real test suite, but it serves our purposes in this experiment. OK, let’s run the test suite, already!
main :: IO ()
= run mySpec main
Looking at the output, we see that “once, before all!” is printed only once, and that “before each …” is printed for every test.
*Test.Spec> :main
module 1 > feature A > it works!
before all!
before each 1!
module 1 > feature A > it works again!
before each 1!
module 2 > feature B > it works!
before each 2!
module 2 > feature B > it works again!
before each 2!
Summary
Using tagless final style for embedded languages is fun and powerful. I hope this demonstration can serve as a motivating example for readers interested in EDSLs and tagless final style, in addition to it perhaps influencing or finding its way into the PureScript Spec project. It would also be interesting to explore a Free monad approach, and to compare the two.
Revisions
June 5, 2017: Based on feedback from Matthias Heinzel, regarding the order of execution of setup actions, the expression (<*> setup)
in the beforeEach
instance was changed to ((&) <$> setup <*>)
.