Tastier Options

Posted on December 19, 2025
An exercise in parameterizing tasty test-suites using the CLI.

I encountered a pretty simple problem recently, or how it’s probably better phrased, I recently made my life a bit harder by thinking: “What if?”. I needed to pass multiple arguments via the CLI to a tasty test-suite to use in some tests. As an example, I’ll pass the connection string to use to connect to a PostgreSQL database. That could go something like the following.

Typically, you’d tell tasty about the existence of your new option via includingOptions, and then access these options via askOption.
-- Create the option in the style of tasty.
data PgConnectionString = PgConnectionString Text
instance IsOption PgConnectionString where ...

-- `defaultMainWithIngredients` gives us an ingredient to
-- plug the option into, so it gets shown in the CLI.
main = do
  defaultMainWithIngredients
    [ includingOptions [Option (Proxy @PgConnectionString)]
    ] $ askOption setupTests

-- `askOption` propogates it so we can setup our test-suite
-- to make the appropriate calls.
setupTests :: PgConnectionString -> TestTree
setupTests _ = undefined

That’s all nice and all, but the querying doesn’t really extend in a nice way. Concretely, I’d need to call askOption for each individual option I’d want to add to my setupTests function.
data PgPoolSize = PgPoolSize Text
instance IsOption PgPoolSize where ...

main = do
  defaultMainWithIngredients
    [ includingOptions
      [ Option (Proxy @PgConnectionString)
      , Option (Proxy @PgPoolSize)
      ]
    ] $
      askOption $ \(connectionString :: PgConnectionString) ->
        askOption $ \(poolSize :: PgPoolSize) ->
          setupTests connectionString poolSize

What if I could write a helper that did this passing around of options for me for free?
main = do
    defaultMainWithIngredients
      [ includingOptions
        [ Option (Proxy @PgConnectionString)
        , Option (Proxy @PgPoolSize)
        ]
      ] $ withTests setupTests

As has been a recurring pattern in a few of my posts, variadic functions come to the rescue! We want to build up a variadic continuation (like last time) that setupTests can be passed to. In this case we want instance resolution to keep adding arguments via askOption at each resolution step.

Continuation functions generally have the pattern of forall c. (a -> c) -> c, and considering we’re intending on giving this function our test setup, some good intuition (or practical experimentation) gives something of the form forall r. ((a -> c) -> r) -> r. Here a will play the role of capturing more and more options, and c will capture the TestTree.
I’m not super sure, but I feel like there’s a simpler alternative here that either reduces the number of arrows or the number of type variables in the type class. I kept hitting my head on trying to find a simpler representation though.
class WithOptions a c where
  withOptions_ :: forall r. ((a -> c) -> r) -> r

instance WithOptions TestTree TestTree where
  withOptions_ f = f identity

instance (IsOption o, WithOptions c TestTree)
    => WithOptions (o -> c) TestTree where
  withOptions_ outer = withOptions_ $
    \next -> outer $ \inner -> askOption $
      \option -> next (inner option)

A nice benefit of these variadic functions is, once you’ve found your core definition and how the incremental step should be setup, the implementation flows out of the type variables you’ve used. In this case the base case of the continuation is the identity function, and in the incremental step, we call askOption with the appropriate type. This gives the very nice final interface of…
withOptions :: WithOptions a TestTree => a -> TestTree
withOptions f = withOptions_ $ \addOptions -> addOptions f

main = do
  defaultMainWithIngredients
    [ includingOptions
      [ Option (Proxy @PgConnectionString)
      , Option (Proxy @PgPoolSize)
      ]
    ] $ withOptions setupTests