Dynamic Test Suites in Haskell using Hspec and Tasty

Test suites with many example-based tests can contain a lot of repetition. While it's possible to factor out much of the repetition using regular Haskell code, it can be useful to construct test cases from external files, which can be generated from other sources, or constructed by people not doing Haskell programming. In this video I'll demonstrate how to create a dynamic test suite based on examples in an external CSV file. The tests can be run individually using Tasty patterns. It is easy to add new examples to the CSV, and the Haskell test code doesn't even need recompilation.

Show Notes

The function that we’ll be testing is called pluralize. It’s a function of type Text -> Text, which, given an empty string, returns an empty string. Given any other string, we will check if the last character is an s, and if so, we’ll return the string. Otherwise, we’ll append an s at the end.

pluralize :: Text -> Text
pluralize "" = ""
pluralize t =
  case Text.last t of
    's' -> t
    _ -> t <> "s"

We can try this function out in the REPL:

*Lib> pluralize "dog"
"dogs"
*Lib> pluralize "dogs"
"dogs"
*Lib> pluralize "cats"
"cats"
*Lib> pluralize "cat"
"cats"

Now, we’d like to test this function. In a Tasty test file, we can define an Hspec test using spec_ followed by a name. Using do notaion, we can combine a bunch of test cases.

spec_pluralize :: Spec
spec_pluralize = do

  it "pluralizes 'cat' to 'cats'" $
    pluralize "cat" `shouldBe` "cats"

  it "pluralizes 'cats' to 'cats'" $
    pluralize "cats" `shouldBe` "cats"

  it "pluralizes 'dog' to 'dogs'" $
    pluralize "dog" `shouldBe` "dogs"

  it "pluralizes 'dogs' to 'dogs'" $
    pluralize "dogs" `shouldBe` "dogs"

Running the test suite in the REPL shows us that all tests pass:

*Main Lib LibTest> :main
/home/owi/coda/dynamic-test-suite/test/Main.hs
  pluralize
    pluralizes 'cat' to 'cats':  OK
    pluralizes 'cats' to 'cats': OK
    pluralizes 'dog' to 'dogs':  OK
    pluralizes 'dogs' to 'dogs': OK

All 4 tests passed (0.00s)
*** Exception: ExitSuccess

These test cases have a lot of repetition. We’d like to keep separate test cases, but not repeat all the code. Let’s instead write an external file called test/plurals.csv. Here we list all inputs and their corresponding expected outputs, like cat becoming cats.

cat,cats
cats,cats
dog,dogs
dogs,dogs

This file could be edited in a spreadsheet editor, or generated from some external source, and contain thousands of examples. Let’s rewrite our test case file to use this external CSV file, instead.

We need some new imports:

import           Control.Monad
import           Data.Semigroup
import qualified Data.Text.IO     as Text
import           Text.Printf

We remove some of the hard-coded examples from before, and instead define readExamples, an IO action return [(Text, Text)]. It’s defined as mapping the asPair function over the lines read from a file; test/plurals.csv, the file we wrote before. In case there are two items on a line, we return those in a tuple, otherwise we will fail with an error message.

readExamples :: IO [(Text, Text)]
readExamples =
  mapM asPair =<< Text.lines <$> Text.readFile "test/plurals.csv"
  where
    asPair line =
      case Text.splitOn "," line of
        [input, expected] -> pure (input, expected)
        _ -> fail ("Invalid example line: " <> Text.unpack line)

If you’re parsing more complex CSV data, consider using the Cassava package, instead of manually splitting lines with text.

Now, here comes the trick. In our spec we will use runIO to embed an IO action:

spec_pluralize :: Spec
spec_pluralize = do
  examples <- runIO readExamples
  ...

With our examples read, we can loop over them using forM_, and construct a test case for each example. We will generate test cases looking like before, but using the text strings from the file.

  ...
  forM_ examples $ \(input, expected) ->
    it (printf "pluralizes '%s' to '%s'" input expected) $
      pluralize input `shouldBe` expected

Running the test suite gives us the same result as before, when we had examples written directly with Hspec.

Now, someone figures out that some words aren’t pluralized correctly; words like fungus and schema.

cat,cats
cats,cats
dog,dogs
dogs,dogs
fungus,fungi
schema,schemata

We run the test suite, and see that we have two new failures:

*Main Lib LibTest> :main
/home/owi/coda/dynamic-test-suite/test/Main.hs
  pluralize
    pluralizes 'cat' to 'cats':        OK
    pluralizes 'cats' to 'cats':       OK
    pluralizes 'dog' to 'dogs':        OK
    pluralizes 'dogs' to 'dogs':       OK
    pluralizes 'fungus' to 'fungi':    FAIL
      expected "fungi", but got "fungus"
    pluralizes 'schema' to 'schemata': FAIL
      expected "schemata", but got "schemas"

2 out of 6 tests failed (0.00s)

If you add hundreds, or thousands, of these example, you might want to filter them to only show the failures. We can do that using by setting the environment TASTY_HIDE_SUCCESSES to the string True. Restarting the REPL and running the tests, we see only the failures:

$ TASTY_HIDE_SUCCESSES=True stack test
...

  pluralize
    pluralizes 'fungus' to 'fungi':    FAIL
      expected "fungi", but got "fungus"
    pluralizes 'schema' to 'schemata': FAIL
      expected "schemata", but got "schemas"

2 out of 6 tests failed (0.01s)

Exporting an environment variable in Vim can be done using let.

You might want to only run specific test cases. You can use the -p flag in Tasty with a pattern. Here we only run the fungus test:

$ stack test --ta '-p fungus'
...

  pluralize
    pluralizes 'fungus' to 'fungi': FAIL
      expected "fungi", but got "fungus"

1 out of 1 tests failed (0.00s)

As commented by @mwotton, if the extra-source-files field in the project’s Cabal file includes the CSV file path, stack test --file-watch will rerun tests on changes to the CSV file.

OK, let’s fix our implementation, even if somewhat naively. We pattern match on those two special cases:

pluralize :: Text -> Text
pluralize "" = ""
pluralize "fungus" = "fungi"
pluralize "schema" = "schemata"
pluralize t =
  case Text.last t of
    's' -> t
    _   -> t <> "s"

We rerun the tests, and see that they all pass.

That’s it! You can use runIO to embed IO actions in Hspec, and dynamically create your test suite.

Thank you for watching and reading!