HSpec Advanced Patterns: Hooks, Shared Context, and Parallel Tests
HSpec is Haskell's most widely used test framework for unit and integration tests.
HSpec is Haskell's most widely used test framework for unit and integration tests. Beyond basic describe/it blocks, it offers hooks, shared context, custom matchers, and tight integration with QuickCheck. This guide covers the patterns that matter for large test suites.
Setup and Structure Refresher
import Test.Hspec
main :: IO ()
main = hspec spec
spec :: Spec
spec = do
describe "User service" $ do
it "creates users with default role" $ do
let user = createUser "alice" "alice@example.com"
userRole user `shouldBe` "member"
it "rejects duplicate emails" $ do
createUserInDB "alice@example.com"
result <- tryCreateUserInDB "alice@example.com"
result `shouldBe` Left DuplicateEmailBefore and After Hooks
import Test.Hspec
spec :: Spec
spec = do
describe "Database tests" $ do
before setupDB $ do
it "inserts a record" $ \db -> do
insert db testRecord
count db `shouldReturn` 1
it "queries records" $ \db -> do
insert db testRecord
results <- query db
length results `shouldBe` 1
after teardownDB $ do
it "cleans up after itself" $ \db -> do
-- teardown runs after this
pure ()before runs an action and passes the result to each test. after receives the value and runs cleanup.
Before/After All
spec :: Spec
spec = do
describe "Integration tests" $
beforeAll setupTestServer $ \server -> do
afterAll stopServer $ do
it "responds to health check" $ do
response <- get server "/health"
statusCode response `shouldBe` 200
it "handles authentication" $ do
response <- getWithAuth server "/api/users"
statusCode response `shouldBe` 200beforeAll runs once before the group; afterAll runs once after all tests complete. The server value is shared across the entire group.
Around: Full Setup/Teardown
withTempDB :: ActionWith Connection -> IO ()
withTempDB action = do
conn <- connectToTestDB
migrate conn
action conn `finally` disconnect conn
spec :: Spec
spec = around withTempDB $ do
it "creates a user" $ \conn -> do
userId <- createUser conn "alice"
userId `shouldSatisfy` (> 0)
it "lists users" $ \conn -> do
createUser conn "alice"
createUser conn "bob"
users <- listUsers conn
length users `shouldBe` 2around is the most flexible hook — it wraps each test with full setup and teardown control.
Shared Context
Avoid repeating setup across specs by defining context combinators:
-- Shared.hs
withAuthenticatedUser :: SpecWith (App, User) -> Spec
withAuthenticatedUser = beforeAll $ do
app <- startTestApp
user <- createTestUser app
_ <- login app user
return (app, user)
-- UserSpec.hs
spec :: Spec
spec = withAuthenticatedUser $ do
it "can view profile" $ \(app, user) -> do
response <- getProfile app (userId user)
responseUser response `shouldBe` user
it "can update display name" $ \(app, user) -> do
update app (userId user) "New Name"
updated <- getProfile app (userId user)
displayName updated `shouldBe` "New Name"Custom Matchers
Define domain-specific matchers for clearer failure messages:
import Test.Hspec
import Test.Hspec.Expectations
-- Custom matcher
shouldBeValid :: (Show a, Validatable a) => a -> Expectation
shouldBeValid x =
case validate x of
Right _ -> return ()
Left errs -> expectationFailure $
"Expected valid, but got errors: " ++ show errs
shouldHaveStatus :: Response -> Int -> Expectation
shouldHaveStatus resp expected =
statusCode resp `shouldBe` expected
-- Usage
spec :: Spec
spec = do
it "validates a complete user" $ do
let user = User "alice" "alice@example.com" 25
user `shouldBeValid`
it "returns 200 for valid request" $ do
response <- makeRequest
response `shouldHaveStatus` 200Pending Tests
Mark tests that aren't implemented yet:
spec :: Spec
spec = do
it "handles concurrent writes" pending
it "supports pagination" $ do
pendingWith "waiting for pagination API"
xit "this test is temporarily disabled" $ do
-- body is defined but marked as expected failure
1 `shouldBe` 2pending marks a test as not yet implemented. pendingWith adds a reason. xit marks a test as expected to fail.
Parallel Test Execution
import Test.Hspec
import Test.Hspec.Core.Runner
main :: IO ()
main = hspecWith defaultConfig { configConcurrentJobs = Just 4 } specOr via command line: runhaskell Spec.hs --jobs 4
Mark specific groups for parallel execution:
spec :: Spec
spec = parallel $ do
describe "Independent tests" $ do
it "test A" $ ...
it "test B" $ ...
it "test C" $ ...Tests within parallel run concurrently. Tests outside it run sequentially.
QuickCheck Integration
import Test.Hspec
import Test.Hspec.QuickCheck
import Test.QuickCheck
spec :: Spec
spec = do
describe "sort" $ do
prop "preserves length" $
\(xs :: [Int]) -> length (sort xs) == length xs
prop "idempotent" $
\(xs :: [Int]) -> sort (sort xs) == sort xs
-- Customized QuickCheck options
modifyMaxSuccess (const 1000) $
prop "handles large inputs" $
\(xs :: [Int]) -> length (sort xs) == length xsprop automatically runs QuickCheck with 100 tests by default. modifyMaxSuccess adjusts the count for a subtree.
Focused Tests
Run only specific tests during development:
spec :: Spec
spec = do
describe "User" $ do
fit "only this test runs" $ do -- focused
1 `shouldBe` 1
it "this is skipped" $ do
2 `shouldBe` 2fit (focused it) and fdescribe (focused describe) cause HSpec to run only focused tests. Remove them before committing.
Expectations Reference
-- Equality
x `shouldBe` y
x `shouldNotBe` y
-- IO equality
action `shouldReturn` value
-- Boolean
x `shouldSatisfy` predicate
x `shouldNotSatisfy` predicate
-- Exceptions
action `shouldThrow` anyException
action `shouldThrow` (== MyException "message")
action `shouldThrow` anyIOException
-- Lists
xs `shouldContain` [y]
xs `shouldNotContain` [y]
xs `shouldMatchList` ys -- same elements, any order
-- Strings
s `shouldStartWith` prefix
s `shouldEndWith` suffix
-- Null/empty
shouldBe Nothing nothing -- for MaybeTesting with IORef and STM
spec :: Spec
spec = do
describe "Counter" $ do
it "increments correctly" $ do
ref <- newIORef (0 :: Int)
increment ref
increment ref
readIORef ref `shouldReturn` 2
it "handles concurrent increments" $ do
tvar <- newTVarIO (0 :: Int)
let increment = atomically $ modifyTVar tvar (+1)
mapConcurrently_ (const increment) [1..100 :: Int]
readTVarIO tvar `shouldReturn` 100Spec Discovery
HSpec can auto-discover test modules:
-- test/Spec.hs
{-# OPTIONS_GHC -F -pgmF hspec-discover #-}With this single file, HSpec finds all *Spec.hs files under test/ and runs them. Each must export a spec :: Spec value.
Add to cabal:
test-suite spec
type: exitcode-stdio-1.0
main-is: Spec.hs
hs-source-dirs: test
build-depends:
base, hspec, hspec-discover
build-tool-depends: hspec-discover:hspec-discoverHSpec's combination of readable syntax, flexible hooks, and QuickCheck integration makes it the right choice for most Haskell projects. The around pattern handles even complex stateful tests cleanly, and hspec-discover removes the boilerplate of wiring test modules together.