HSpec Advanced Patterns: Hooks, Shared Context, and Parallel Tests

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 DuplicateEmail

Before 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` 200

beforeAll 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` 2

around 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` 200

Pending 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` 2

pending 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 } spec

Or 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 xs

prop 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` 2

fit (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 Maybe

Testing 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` 100

Spec 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-discover

HSpec'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.

Read more