HSpec Advanced Patterns: Hooks, Shared Context, and Parallel 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.