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 TastyBasic 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 ageAssertions 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 == xsThe "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 actionsState 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 actionsThis 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 === 0Use 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 testsQuickCheck 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.).