Skip to content
AgileLogics
7 min read

Unit Testing with HUnit and Hspec

Pure functions are the easiest code in the world to test: same input, same output, no setup. Haskell's testing ecosystem has three main libraries, often used together:

  • HUnit — classic unit tests with assertions
  • Hspec — a behavior-driven framework (describe/it) that runs HUnit-style and QuickCheck tests side by side
  • QuickCheck — property-based testing (next lesson)

Add them to your test suite's build-depends in the .cabal file (hspec, HUnit, QuickCheck) and cabal downloads them from Hackage.

HUnit

import Test.HUnit
import System.IO (stdout)

slugify :: String -> String
slugify = map (\c -> if c == ' ' then '-' else c)

tests :: Test
tests =
  TestList
    [ "spaces become dashes" ~: slugify "hello world" ~?= "hello-world"
    , "empty string" ~: slugify "" ~?= ""
    , "lowercases" ~: slugify "Hello" ~?= "hello"
    ]

main :: IO ()
main = do
  (counts, _) <- runTestText (putTextToHandle stdout False) tests
  print counts
### Failure in: 2:lowercases
main.hs:12
expected: "hello"
 but got: "Hello"
Cases: 3  Tried: 3  Errors: 0  Failures: 1
Counts {cases = 3, tried = 3, errors = 0, failures = 1}
  • label ~: test names a test; actual ~?= expected asserts equality.
  • The third test fails on purpose, and the report shows the expected and actual values. (runTestTT tests is the shorter way to run them; it also prints progress to the terminal.)
  • In a cabal test suite, exit with a failure code when tests fail, so cabal test and CI notice — Hspec does this for you.

Hspec

Hspec reads like a specification:

-- illustrative: test/Spec.hs, run with cabal test
import Test.Hspec
import Control.Exception (evaluate)

slugify :: String -> String
slugify = map (\c -> if c == ' ' then '-' else c)

main :: IO ()
main = hspec $ do
  describe "slugify" $ do
    it "replaces spaces with dashes" $
      slugify "hello world" `shouldBe` "hello-world"
    it "leaves an empty string alone" $
      slugify "" `shouldBe` ""
  describe "div" $ do
    it "throws on division by zero" $
      evaluate (1 `div` (0 :: Int)) `shouldThrow` anyArithException
  describe "division" $ do
    it "is exact for multiples" $
      (12 `div` 4) `shouldSatisfy` (== 3)
slugify
  replaces spaces with dashes [✔]
  leaves an empty string alone [✔]
div
  throws on division by zero [✔]
division
  is exact for multiples [✔]

Finished in 0.0008 seconds
4 examples, 0 failures
  • shouldBe, shouldSatisfy, shouldReturn (for IO actions), and shouldThrow cover most assertions.
  • hspec-discover can find every *Spec.hs file automatically, so large projects put one spec module next to each source module.
  • Run a subset with cabal test --test-options='--match "slugify"'.

What to test

Keep IO at the edges (Level 2) and most of your code is pure — test it directly. For code that does IO, pass the effectful parts in as arguments or use a type class (see the mtl lesson), so tests can supply fakes. The doctest tool also runs the >>> examples in Haddock comments as tests, which keeps documentation honest.