← Back to DevBytes

Testing Strategies for Haskell Applications

Introduction to Testing in Haskell

Haskell's strong static type system eliminates entire categories of bugs that plague dynamically-typed languages, but it is not a substitute for testing. Types guarantee that functions accept and return the right shapes of data, but they cannot guarantee that a sorting algorithm actually sorts, that a database query returns the correct rows, or that a web endpoint handles edge cases gracefully. A robust testing strategy in Haskell combines the language's unique strengths — purity, laziness, and expressive type system — with both traditional and property-based testing techniques.

This tutorial covers the major testing approaches used in production Haskell applications: unit testing, property-based testing with QuickCheck, integration testing, and golden testing. We will also discuss how to structure test suites, manage effects, and integrate testing into your CI pipeline.

Why Testing Matters in Haskell

Even with a powerful type system, Haskell applications can fail in subtle ways. Logic errors, incorrect business rules, off-by-one mistakes, and unexpected interactions with external systems all slip past the compiler. Testing matters because:

Setting Up a Test Suite

The standard testing framework in the Haskell ecosystem is tasty, which acts as an umbrella runner that can combine unit tests, property tests, and other test types into a single suite. The most common libraries you will use are:

In your package.yaml or .cabal file, define a test suite stanza:

tests:
  myapp-test:
    main: Spec.hs
    source-dirs: test
    ghc-options:
      - -threaded
      - -rtsopts
      - -with-rtsopts=-N
    dependencies:
      - myapp
      - tasty
      - tasty-hunit
      - tasty-quickcheck
      - QuickCheck

Your test/Spec.hs entry point simply runs the main test tree:

module Main (main) where

import Test.Tasty

import MyLibTests (myLibTests)
import PropertyTests (propertyTests)

main :: IO ()
main = defaultMain tests

tests :: TestTree
tests = testGroup "All Tests"
  [ myLibTests
  , propertyTests
  ]

Unit Testing with HUnit

Unit testing in Haskell follows the familiar arrange-act-assert pattern. Because most functions are pure, you do not need to set up complex mocking frameworks. You simply call the function and compare the result to an expected value.

Basic Unit Tests

module MyLibTests (myLibTests) where

import Test.Tasty
import Test.Tasty.HUnit
import MyApp.MyLib (parseConfig, validateEmail, sumList)

myLibTests :: TestTree
myLibTests = testGroup "MyLib Unit Tests"
  [ testCase "sumList of empty list is 0" $
      sumList [] @?= 0

  , testCase "sumList of positive integers" $
      sumList [1, 2, 3, 4, 5] @?= 15

  , testCase "validateEmail accepts standard address" $
      validateEmail "user@example.com" @?= True

  , testCase "validateEmail rejects missing domain" $
      validateEmail "user@" @?= False

  , testCase "parseConfig parses valid YAML-like input" $
      parseConfig "name: test\nport: 8080" @?=
        Right (Config { configName = "test", configPort = 8080 })

  , testCase "parseConfig returns error on malformed input" $
      case parseConfig "!!!invalid" of
        Left _  -> return ()
        Right _ -> assertFailure "Expected parse failure but got success"
  ]

The @?= operator asserts equality with an expected value, while @? asserts that a boolean expression is true. For testing error cases where the exact error message may vary, pattern matching with assertFailure is a clean approach.

Testing Effectful Functions

When functions return IO, you can still test them directly within testCase since the test body runs in IO:

testCase "readFileContent returns file contents" $ do
  content <- readFileContent "test/fixtures/sample.txt"
  content @?= "expected content here"

testCase "writeThenRead roundtrips correctly" $ do
  let path = "/tmp/test_roundtrip.txt"
  writeFileContent path "hello world"
  result <- readFileContent path
  result @?= "hello world"

Property-Based Testing with QuickCheck

Property-based testing is where Haskell's testing story truly excels. Instead of writing individual test cases with hardcoded inputs, you describe properties that your functions should satisfy for all inputs. QuickCheck then generates hundreds or thousands of random inputs and checks whether the property holds. When a test fails, QuickCheck automatically shrinks the failing input to a minimal counterexample.

Your First Property

module PropertyTests (propertyTests) where

import Test.Tasty
import Test.Tasty.QuickCheck
import MyApp.MyLib (reverseList, sortList, encodeDecode)

propertyTests :: TestTree
propertyTests = testGroup "Property Tests"
  [ testProperty "reversing twice yields original" $
      \xs -> reverseList (reverseList xs) === (xs :: [Int])

  , testProperty "sort produces ordered output" $
      \xs -> let sorted = sortList xs
             in sorted === sortList sorted

  , testProperty "sort preserves length" $
      \xs -> length (sortList xs) === length (xs :: [Int])

  , testProperty "encode . decode = identity" $
      \s -> encodeDecode (s :: String) === Right s
  ]

The === operator is preferred over == in QuickCheck because it provides better counterexample output. Notice how each property is a function from generated inputs to a boolean-like result. QuickCheck uses the Arbitrary typeclass to know how to generate random values of each type.

Custom Generators

For domain-specific types, you need to write custom Arbitrary instances. This is where property testing becomes powerful — you can generate realistic, constrained test data.

{-# LANGUAGE DeriveGeneric #-}

module MyApp.Domain (User(..), Age, mkAge, unAge) where

import Test.QuickCheck
import GHC.Generics (Generic)

newtype Age = Age { unAge :: Int }
  deriving (Eq, Show)

mkAge :: Int -> Maybe Age
mkAge n
  | n >= 0 && n <= 150 = Just (Age n)
  | otherwise          = Nothing

data User = User
  { userName :: String
  , userAge  :: Age
  } deriving (Eq, Show, Generic)

instance Arbitrary Age where
  arbitrary = Age <$> choose (0, 150)

instance Arbitrary User where
  arbitrary = do
    name <- listOf $ elements ['a'..'z']
    age   <- arbitrary
    return $ User
      { userName = if null name then "default" else name
      , userAge  = age
      }

You can also create named generators for more complex scenarios:

genNonEmptyString :: Gen String
genNonEmptyString = listOf1 $ elements ['a'..'z']

genUserWithLongName :: Gen User
genUserWithLongName = do
  name <- resize 50 $ listOf1 $ elements ['a'..'z']
  age  <- arbitrary
  return $ User name age

-- Use it in a test:
testProperty "users with long names are valid" $
  forAll genUserWithLongName $ \user ->
    length (userName user) > 0

Conditional Properties

Sometimes a property only holds under certain conditions. Use ==> to add preconditions, but be careful — if the condition is too restrictive, QuickCheck may discard too many generated inputs:

testProperty "mkAge roundtrips for valid ages" $
  \n -> n >= 0 && n <= 150 ==>
    case mkAge n of
      Just age -> unAge age === n
      Nothing  -> property False

Shrinking

When a property fails, QuickCheck attempts to find a smaller counterexample. For custom types, you can implement shrink to guide this process:

instance Arbitrary Age where
  arbitrary = Age <$> choose (0, 150)
  shrink (Age n) = [Age m | m <- shrink n, m >= 0, m <= 150]

Good shrinking is what makes property-based debugging practical. A failing test on a list of 500 elements is hard to reason about, but QuickCheck can shrink it down to a list of 2 or 3 elements that still triggers the bug.

Hedgehog: An Alternative to QuickCheck

Hedgehog is a newer property testing library that integrates shrinking into the generator itself, rather than requiring a separate shrink implementation. This means every custom generator gets shrinking for free. Many teams prefer Hedgehog for this reason.

{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE OverloadedStrings #-}

module HedgehogTests (hedgehogTests) where

import Hedgehog
import qualified Hedgehog.Gen as Gen
import qualified Hedgehog.Range as Range
import Test.Tasty
import Test.Tasty.Hedgehog

prop_reverse_twice :: Property
prop_reverse_twice = property $ do
  xs <- forAll $ Gen.list (Range.linear 0 100) Gen.alpha
  reverse (reverse xs) === xs

prop_sort_idempotent :: Property
prop_sort_idempotent = property $ do
  xs <- forAll $ Gen.list (Range.linear 0 100) Gen.int (Range.linear (-100) 100)
  let once = sort xs
  sort once === once

hedgehogTests :: TestTree
hedgehogTests = testGroup "Hedgehog Tests"
  [ testProperty "reverse twice" prop_reverse_twice
  , testProperty "sort idempotent" prop_sort_idempotent
  ]

With Hedgehog, the range specification (Range.linear 0 100) is built into the generator, and shrinking happens automatically based on the generator structure.

Golden Testing

Golden testing compares the output of a function against a pre-recorded expected output stored in a file. This is especially useful for testing things like pretty-printers, code generators, serialization formats, and report generation.

module GoldenTests (goldenTests) where

import Test.Tasty
import Test.Tasty.Golden
import System.FilePath ((</>))
import MyApp.Renderer (renderReport)

goldenTests :: TestTree
goldenTests = testGroup "Golden Tests"
  [ goldenVsFile "render sample report"
      "test/golden/expected_report.txt"
      "test/golden/actual_report.txt"
      (renderReport "test/fixtures/sample_data.json"
                    "test/golden/actual_report.txt")

  , goldenVsStringDiff "render inline report"
      (\ref new -> ["diff", "-u", ref, new])
      "test/golden/expected_inline.txt"
      (return $ renderReportToString "test/fixtures/sample_data.json")
  ]

When a golden test fails, tasty-golden shows a diff. You can then update the golden file by running the test suite with the --accept flag, which regenerates the expected output. This workflow is ideal for outputs that change intentionally during development.

Integration Testing

Integration tests verify that multiple components work together correctly, often involving external systems like databases or HTTP servers. In Haskell, the ResourceT monad or bracket patterns help manage setup and teardown cleanly.

Testing a Database Layer

{-# LANGUAGE OverloadedStrings #-}

module IntegrationTests (integrationTests) where

import Test.Tasty
import Test.Tasty.HUnit
import Database.SQLite.Simple
import MyApp.Repository (createUser, getUserById, initSchema)
import Control.Exception (bracket)

withTestDb :: (Connection -> IO a) -> IO a
withTestDb action =
  bracket (open ":memory:") close $ \conn -> do
    initSchema conn
    action conn

integrationTests :: TestTree
integrationTests = testGroup "Database Integration Tests"
  [ testCase "create and retrieve user" $ withTestDb $ \conn -> do
      let user = User "alice" (Age 30)
      userId <- createUser conn user
      result  <- getUserById conn userId
      result @?= Just user

  , testCase "getUserById returns Nothing for missing user" $
      withTestDb $ \conn -> do
        result <- getUserById conn 99999
        result @?= Nothing

  , testCase "duplicate username is rejected" $ withTestDb $ \conn -> do
      _ <- createUser conn (User "bob" (Age 25))
      result <- try $ createUser conn (User "bob" (Age 40))
      case result of
        Left (SQLError{}) -> return ()
        Right _           -> assertFailure "Expected unique constraint violation"
  ]

Using an in-memory SQLite database makes tests fast and isolated. Each test gets a fresh database, eliminating cross-test contamination.

Testing HTTP APIs

For web applications built with frameworks like servant or scotty, you can test the full request-response cycle without starting an actual server:

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}

module ApiTests (apiTests) where

import Test.Tasty
import Test.Tasty.HUnit
import Network.Wai.Test
import Network.HTTP.Types (methodGet, methodPost, status200, status404)
import MyApp.Api (app)

apiTests :: TestTree
apiTests = testGroup "API Tests"
  [ testCase "GET /health returns 200" $ do
      response <- runSession (request methodGet "/health" [] "") app
      assertStatus 200 response

  , testCase "GET /users/:id returns user JSON" $ do
      response <- runSession (request methodGet "/users/1" [] "") app
      assertStatus 200 response
      assertBody "{\"id\":1,\"name\":\"alice\",\"age\":30}" response

  , testCase "GET /users/:id returns 404 for missing user" $ do
      response <- runSession (request methodGet "/users/99999" [] "") app
      assertStatus 404 response
  ]

Testing Effectful Code with the MTL Pattern

When your application uses monad transformers and typeclasses to abstract effects (the "mtl style"), testing becomes particularly elegant. You can define a pure test interpreter for your effect typeclass and run business logic without any real side effects.

{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE DerivingStrategies #-}

module MyApp.Effects where

import Control.Monad.Reader
import Control.Monad.Except

class Monad m => MonadLogger m where
  logMsg :: String -> m ()

class Monad m => MonadDb m where
  saveRecord :: Record -> m RecordId
  getRecord  :: RecordId -> m (Maybe Record)

class Monad m => MonadTime m where
  currentTime :: m UTCTime

-- Production interpreter runs in IO
-- Test interpreter runs in pure State

newtype TestApp a = TestApp
  { unTestApp :: ReaderT TestEnv (ExceptT AppError (State TestState)) a }
  deriving newtype
    ( Functor, Applicative, Monad
    , MonadReader TestEnv, MonadError AppError, MonadState TestState
    )

instance MonadLogger TestApp where
  logMsg msg = modify' $ \s -> s { tsLogs = msg : tsLogs s }

instance MonadDb TestApp where
  saveRecord r = do
    nextId <- gets tsNextId
    modify' $ \s -> s
      { tsRecords = Map.insert nextId r (tsRecords s)
      , tsNextId  = nextId + 1
      }
    return nextId

  getRecord rid = gets $ Map.lookup rid . tsRecords

instance MonadTime TestApp where
  currentTime = asks teFixedTime

Now your business logic can be tested completely deterministically:

testCase "createRecord logs and persists" $ do
  let env    = TestEnv (UTCTime (fromGregorian 2024 1 1) 0)
      state0 = TestState { tsRecords = Map.empty, tsNextId = 1, tsLogs = [] }
      result = runState
                 (runExceptT (runReaderT (unTestApp createRecordAndLog) env))
                 state0
  case result of
    (Right rid, finalState) -> do
      rid @?= 1
      Map.lookup 1 (tsRecords finalState) @?= Just expectedRecord
      tsLogs finalState @?= ["Created record 1"]
    (Left err, _) ->
      assertFailure $ "Unexpected error: " ++ show err

Best Practices

1. Test Pure Logic Heavily

Haskell makes it easy to push logic into pure functions. Take advantage of this. The more of your application that lives in pure functions, the easier it is to test with both unit and property tests. Keep IO at the edges of your application — reading input and writing output — and test the pure core exhaustively.

2. Prefer Property Tests for Algorithmic Code

Whenever a function has a mathematical or structural property (associativity, identity, idempotence, round-trip encoding), write a property test. Properties are more expressive than unit tests and often catch edge cases you would never think to write manually.

3. Keep Tests Fast and Isolated

Use in-memory databases, pure interpreters, and ResourceT to keep tests fast. A test suite that runs in under 10 seconds encourages developers to run it frequently. Avoid tests that depend on external services in CI — use containerized versions or stubs instead.

4. Write Custom Generators for Domain Types

Do not rely on default Arbitrary instances for complex domain types. Write generators that produce realistic data. This investment pays off because every property test using that generator benefits from better inputs.

5. Use === Over == in QuickCheck

The === operator produces a counterexample showing both the expected and actual values when a test fails. This makes debugging dramatically easier.

6. Structure Tests to Mirror Source Structure

If your source has src/MyApp/Repository.hs, your tests should have test/MyApp/RepositoryTests.hs. This makes it trivial to find the tests for any module.

7. Set --quickcheck-tests Higher for CI

By default, QuickCheck runs 100 tests per property. In CI, consider bumping this to 1000 or more:

main :: IO ()
main = defaultMainWithIngredients
  [ includingOptions
      [ QuickCheckTests 1000
      , QuickCheckMaxSize 200
      ]
  , defaultIngredients
  ] tests

8. Use cabal test or stack test in CI

Both build tools integrate cleanly with tasty. Ensure your CI pipeline runs the full test suite on every push and pull request. Consider also running tests with -Werror to catch warnings early.

9. Measure Test Coverage with hpc

Haskell ships with the Haskell Program Coverage tool. Run your tests with coverage enabled to identify untested code paths:

cabal test --enable-coverage
# Or manually:
ghc -fhpc -isrc test/Spec.hs
./Spec
hpc markup Spec.tix --exclude=Main --exclude=MyApp.Test.*

This generates an HTML report showing which expressions and branches were exercised.

Conclusion

Testing in Haskell is a layered discipline that leverages the language's purity and type system to make verification both easier and more powerful than in most languages. Unit tests handle specific cases, property-based testing with QuickCheck or Hedgehog explores vast input spaces automatically, golden tests lock down output formats, and integration tests verify that components compose correctly. By pushing business logic into pure functions, abstracting effects with typeclasses, and writing custom generators for domain types, you can build a test suite that is fast, deterministic, and genuinely catches bugs before they reach production. The investment in a thorough testing strategy pays continuous dividends as your application grows and evolves, giving you the confidence to refactor aggressively and ship reliably.

— Ad —

Google AdSense will appear here after approval

← Back to all articles