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:
- Types do not encode all correctness properties. A function with type
[Int] -> [Int]could return the input unchanged, reverse it, or sort it — the type checker is satisfied by all three. - Pure functions are easy to test. Haskell's separation of pure and effectful code means most business logic is trivially testable without mocks or stubs.
- Property-based testing shines. Haskell's purity makes it an ideal language for generative testing, where the test framework automatically generates thousands of inputs.
- Refactoring is safer. A comprehensive test suite lets you restructure code with confidence, which is essential in a language where abstraction is heavily used.
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:
tasty— the test runnerHUnitortasty-hunit— traditional unit testsQuickCheckortasty-quickcheck— property-based testingtasty-golden— golden file testingtasty-hedgehog— an alternative property testing library with shrinking
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.