Hedgehog Property-Based Testing in Haskell: Shrinking and State Machines

Hedgehog Property-Based Testing in Haskell: Shrinking and State Machines

Hedgehog is a modern property-based testing library for Haskell that addresses QuickCheck's main pain point: shrinking. Where QuickCheck requires manual shrink implementations, Hedgehog shrinks automatically because shrinking is built into the generator itself.

The Key Difference from QuickCheck

In QuickCheck, generation and shrinking are separate. You write an Arbitrary instance with arbitrary and optionally shrink. This means custom types often have poor or absent shrinking.

In Hedgehog, every generator is a Gen a that produces values with their shrink tree embedded. When a failure occurs, Hedgehog traverses the tree automatically. No separate shrink function needed.

Setup

dependencies:
  - hedgehog >= 1.4
  - hspec-hedgehog  # if using HSpec
  - tasty-hedgehog  # if using Tasty

Basic Properties

import Hedgehog
import qualified Hedgehog.Gen as Gen
import qualified Hedgehog.Range as Range

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

main :: IO ()
main = checkParallel $ Group "List properties"
    [ ("reverse involution", prop_reverseInvolution)
    ]

Generators with Range

Hedgehog's Range type controls size scaling:

-- Linear range: scales linearly from min to max with test size
Gen.int (Range.linear 0 100)

-- Constant: always same range regardless of size
Gen.int (Range.constant 1 10)

-- Exponential: more values near the lower bound
Gen.int (Range.exponential 1 1000000)

-- Linear from origin: shrinks toward 0
Gen.int (Range.linearFrom 0 (-100) 100)

Common generators:

-- Text
Gen.text (Range.linear 0 100) Gen.alphaNum
Gen.string (Range.linear 1 50) Gen.ascii

-- Collections
Gen.list (Range.linear 0 20) (Gen.int (Range.linear 0 100))
Gen.set (Range.linear 0 10) Gen.alpha
Gen.map (Range.linear 0 5) Gen.alpha (Gen.int (Range.linear 0 99))

-- Choice
Gen.element ["red", "green", "blue"]
Gen.choice [Gen.constant 0, Gen.int (Range.linear 1 100)]
Gen.frequency [(3, Gen.constant "common"), (1, Gen.constant "rare")]

-- Maybe and Either
Gen.maybe (Gen.int (Range.linear 0 10))
Gen.either Gen.alpha (Gen.int (Range.linear 0 10))

Custom Generators

data User = User
    { userId    :: Int
    , userName  :: Text
    , userEmail :: Text
    , userAge   :: Int
    } deriving (Show, Eq)

genUser :: Gen User
genUser = do
    uid   <- Gen.int (Range.linear 1 99999)
    name  <- Gen.text (Range.linear 2 50) Gen.alphaNum
    local <- Gen.text (Range.linear 1 20) Gen.alpha
    age   <- Gen.int (Range.linear 18 99)
    let email = local <> "@example.com"
    pure $ User uid name email age

Assertions in Hedgehog

Hedgehog uses === for equality and assert for booleans:

prop_userRoundtrip :: Property
prop_userRoundtrip = property $ do
    user <- forAll genUser
    -- Encode then decode should recover original
    decode (encode user) === Just user

prop_sortNonDecreasing :: Property
prop_sortNonDecreasing = property $ do
    xs <- forAll $ Gen.list (Range.linear 0 100) (Gen.int (Range.linear 0 1000))
    let sorted = sort xs
    assert $ and $ zipWith (<=) sorted (drop 1 sorted)

The forAll combinator both generates the value and registers it for display in failure output.

Automatic Shrinking in Action

When a test fails, Hedgehog shows the minimized counterexample automatically:

━━━ prop_myProperty ━━━
✗ <interactive> failed after 13 tests and 2 shrinks.

    ┏━━ src/MySpec.hs ━━━
  5 ┃ prop_myProperty = property $ do
  6 ┃   xs <- forAll $ Gen.list (Range.linear 0 100) (Gen.int (Range.linear 0 10))
    ┃   │ [0,0]    ← minimal counterexample
  7 ┃   sort xs == xs

The "2 shrinks" means Hedgehog found the failure in 13 tests, then reduced it to the smallest failing case in 2 shrinking steps — without any manual shrink code.

State Machine Testing

Hedgehog's most powerful feature is stateful property testing. It generates sequences of commands and verifies that a model stays in sync with the real system.

import Hedgehog.Internal.State

-- Model the expected state
data Model v = Model
    { modelItems :: [Var Int v]  -- tracked references
    , modelCount :: Int
    } deriving (Show)

initialModel :: Model v
initialModel = Model [] 0

-- Commands represent actions
data Add v = Add Int deriving (Show)
data Remove v = Remove (Var Int v) deriving (Show)

-- Add command
commandAdd :: Command Gen IO Model
commandAdd = Command
    { commandGen = \_ -> Just $ Add <$> Gen.int (Range.linear 1 100)
    , commandExecute = \(Add n) -> do
        addToStore n
        -- returns the stored id
    , commandCallbacks =
        [ Update $ \model (Add _) output ->
            model { modelItems = output : modelItems model
                  , modelCount = modelCount model + 1
                  }
        , Ensure $ \before after (Add _) output ->
            modelCount after === modelCount before + 1
        ]
    }

-- Property: run random sequences of commands
prop_storeStateMachine :: Property
prop_storeStateMachine = property $ do
    actions <- forAll $ Gen.sequential
        (Range.linear 1 100)
        initialModel
        [commandAdd, commandRemove]
    executeSequential initialModel actions

State machine tests find bugs that unit tests miss: ordering dependencies, race conditions in sequential logic, and invariant violations after complex operation sequences.

Parallel State Machine Testing

For concurrent code, executeParallel runs commands across multiple threads:

prop_concurrentStore :: Property
prop_concurrentStore = property $ do
    actions <- forAll $ Gen.parallel
        (Range.linear 2 10)
        (Range.linear 2 10)
        initialModel
        [commandAdd, commandRemove]
    recheck (Size 30) (Seed 4143640878477011723 5406025805248357032) $
        executeParallel initialModel actions

This is how Hedgehog finds linearizability violations in concurrent data structures.

Discarding Cases

When you need to filter generated values:

prop_withFilter :: Property
prop_withFilter = property $ do
    n <- forAll $ Gen.int (Range.linear 0 100)
    guard (n `mod` 2 == 0)  -- discard odd numbers
    n `mod` 2 === 0

Use guard sparingly — excessive discards slow tests. Prefer generators that produce only valid values.

Integrating with Tasty

import Test.Tasty
import Test.Tasty.Hedgehog

tests :: TestTree
tests = testGroup "Properties"
    [ testProperty "reverse involution" prop_reverseInvolution
    , testProperty "sort idempotent" prop_sortIdempotent
    , testProperty "encode/decode roundtrip" prop_roundtrip
    ]

main :: IO ()
main = defaultMain tests

QuickCheck vs Hedgehog

Feature QuickCheck Hedgehog
Shrinking Manual shrink Automatic
Generator style Typeclass-based Explicit Gen
State machine testing Via extra library Built-in
Parallel testing No Built-in
Output format Plain text Rich with source annotations

Hedgehog is the better default for new projects. QuickCheck remains useful when you have existing Arbitrary instances or need its ecosystem (Validity, quickcheck-instances, etc.).

Read more