QuickCheck Property-Based Testing in Haskell: A Practical Guide

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 xs

The 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 <= x

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

Stateful 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) x

Configuring 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_withMoreTests

Shrinking 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.

Read more

Start now free