QuickCheck Property-Based Testing in Haskell: A Practical Guide
QuickCheck generates hundreds of random test cases automatically, then shrinks failing cases to the minimal reproducing example. It finds edge cases your manual tests miss—empty lists, integer overflow, Unicode edge cases, deeply nested structures.
Your First Property
A property is a function that returns Bool. QuickCheck calls it with random inputs:
import Test.QuickCheck
prop_reverseInvolution :: [Int] -> Bool
prop_reverseInvolution xs = reverse (reverse xs) == xs
main :: IO ()
main = quickCheck prop_reverseInvolution
-- +++ OK, passed 100 tests.prop_sortIdempotent :: [Int] -> Bool
prop_sortIdempotent xs = sort (sort xs) == sort xs
prop_sortPreservesLength :: [Int] -> Bool
prop_sortPreservesLength xs = length (sort xs) == length xsThe Arbitrary Typeclass
QuickCheck generates values using the Arbitrary typeclass. It ships instances for primitive types, lists, tuples, and most standard types.
sample (arbitrary :: Gen Int)
-- -8 2 -4 15 0 -7 22 -11 34 -19 42
sample (arbitrary :: Gen String)
-- "" "X" "ab" ...Custom Arbitrary Instances
data User = User
{ userId :: Int
, userName :: String
, userEmail :: String
, userAge :: Int
} deriving (Show, Eq)
instance Arbitrary User where
arbitrary = do
uid <- arbitrary `suchThat` (> 0)
name <- listOf1 (elements ['a'..'z'])
email <- do
local <- listOf1 (elements ['a'..'z'])
domain <- listOf1 (elements ['a'..'z'])
return $ local ++ "@" ++ domain ++ ".com"
age <- choose (18, 120)
return User
{ userId = uid
, userName = name
, userEmail = email
, userAge = age
}
shrink user =
[ user { userAge = age' }
| age' <- shrink (userAge user)
]
++
[ user { userName = name' }
| name' <- shrink (userName user)
, not (null name')
]Generators
genValidEmail :: Gen String
genValidEmail = do
local <- listOf1 validChar
domain <- listOf1 validChar
tld <- elements ["com", "org", "net", "io"]
return $ local ++ "@" ++ domain ++ "." ++ tld
where
validChar = elements (['a'..'z'] ++ ['0'..'9'])
genAscendingPair :: Gen (Int, Int)
genAscendingPair = do
lo <- arbitrary
hi <- arbitrary `suchThat` (> lo)
return (lo, hi)
prop_validEmailPasses :: Property
prop_validEmailPasses =
forAll genValidEmail $ \email ->
isRight (validateEmail email)
prop_rangeQueryCorrect :: Property
prop_rangeQueryCorrect =
forAll genAscendingPair $ \(lo, hi) ->
all (\x -> x >= lo && x <= hi) (rangeQuery lo hi)Conditional Properties
prop_divisionRoundsDown :: Int -> Int -> Property
prop_divisionRoundsDown x y =
y /= 0 ==> x `div` y * y <= xIntegration with HSpec
module Data.PriorityQueueSpec (spec) where
import Test.Hspec
import Test.Hspec.QuickCheck
import Test.QuickCheck
import Data.PriorityQueue
spec :: Spec
spec = do
describe "PriorityQueue" $ do
prop "insert then findMin returns inserted element" $
\(x :: Int) -> do
let q = insert x empty
findMin q `shouldBe` Just x
prop "findMin returns minimum element" $
\(xs :: [Int]) ->
not (null xs) ==>
let q = foldr insert empty xs
in findMin q == Just (minimum xs)
prop "size increases by 1 after insert" $
\(x :: Int) (q :: PriorityQueue Int) ->
size (insert x q) == size q + 1
modifyMaxSuccess (* 10) $
prop "important property with more tests" $
\(xs :: [Int]) -> sort (sort xs) == sort xsStateful Testing
Test stateful systems by generating sequences of operations:
data StackOp a = Push a | Pop | Peek
deriving (Show)
instance Arbitrary a => Arbitrary (StackOp a) where
arbitrary = frequency
[ (3, Push <$> arbitrary)
, (2, pure Pop)
, (1, pure Peek)
]
prop_stackMatchesModel :: [StackOp Int] -> Bool
prop_stackMatchesModel ops =
let modelResults = runModel ops []
systemResults = runSystem ops emptyStack
in modelResults == systemResults
runModel :: [StackOp Int] -> [Int] -> [Maybe Int]
runModel [] _ = []
runModel (Push x : ops) model = Nothing : runModel ops (x:model)
runModel (Pop : ops) [] = Nothing : runModel ops []
runModel (Pop : ops) (_:model) = Nothing : runModel ops model
runModel (Peek : ops) [] = Nothing : runModel ops []
runModel (Peek : ops) (x:model) = Just x : runModel ops (x:model)Testing Algebraic Laws
-- Monoid laws
prop_monoidLeftIdentity :: MyMonoid -> Bool
prop_monoidLeftIdentity x = mempty <> x == x
prop_monoidRightIdentity :: MyMonoid -> Bool
prop_monoidRightIdentity x = x <> mempty == x
prop_monoidAssociativity :: MyMonoid -> MyMonoid -> MyMonoid -> Bool
prop_monoidAssociativity x y z = (x <> y) <> z == x <> (y <> z)
-- Functor laws
prop_functorIdentity :: Eq (f Int) => Functor f => f Int -> Bool
prop_functorIdentity x = fmap id x == x
prop_functorComposition
:: (Eq (f Int), Functor f)
=> Fun Int Int -> Fun Int Int -> f Int -> Bool
prop_functorComposition (Fun _ f) (Fun _ g) x =
fmap (f . g) x == (fmap f . fmap g) xConfiguring Test Runs
main :: IO ()
main = quickCheckWith
stdArgs { maxSuccess = 1000, maxSize = 50 }
prop_withMoreTests
-- Replay a specific seed for debugging
main = quickCheckWith
stdArgs { replay = Just (mkQCGen 42, 0) }
prop_withMoreTestsShrinking in Practice
When a property fails, QuickCheck automatically shrinks the input to a minimal counterexample. Implement shrink in your Arbitrary instance to help:
-- Without shrink: failure on [5,2,8,1,9,3,7,4,6]
-- With shrink: failure on [2,1] (minimal counterexample)The shrink function should return a list of "smaller" versions of the input. For numbers, smaller means closer to 0. For lists, shorter lists and lists with smaller elements.
Conclusion
QuickCheck finds bugs that example-based tests miss by generating cases you wouldn't think to write. Write properties as universally-true statements about your code's behavior. Implement Arbitrary with meaningful shrink. Use forAll with focused generators to target interesting input spaces. The combination of generative testing and automatic shrinking makes it the most powerful tool in the Haskell testing toolkit.