QuickCheck in Haskell: Property-Based Testing for Functional Code

QuickCheck in Haskell: Property-Based Testing for Functional Code

Example-based testing asks "does this function return the right value for this specific input?" Property-based testing asks something harder: "does this function behave correctly for every possible input?" QuickCheck, the original property-based testing library that spawned a dozen ports across languages, answers that question by generating hundreds of random inputs and checking that your invariants hold for all of them.

In Haskell, QuickCheck is a natural fit. Pure functions are referentially transparent, which means they're deterministic and safe to call with arbitrary generated inputs. The type system helps QuickCheck know how to generate appropriate values. And when a property fails, QuickCheck shrinks the input to the smallest failing case — giving you a minimal reproducible bug to debug.

This guide covers everything you need to write effective property-based tests with QuickCheck in Haskell.

What Makes Property Testing Different

With example-based tests, you write:

-- Example-based
it "sorts a list" $
  sort [3, 1, 2] `shouldBe` [1, 2, 3]

With property-based tests, you write:

-- Property-based
prop "sort produces a sorted result for any list" $
  \(xs :: [Int]) -> isSorted (sort xs)

The property-based version runs with hundreds of randomly generated lists. It catches edge cases your example list never hits: empty list, single element, duplicates, negative numbers, already-sorted input, reverse-sorted input, large lists.

The tradeoff is that properties are harder to write — you must think about what's true about your function in the abstract, not just for a specific case. But that thinking exercise often reveals misunderstandings in your own mental model before you even run the tests.

Installation

Add to your .cabal file:

test-suite myproject-test
  build-depends: base
               , myproject
               , QuickCheck >= 2.14
               , hspec
               , hspec-quickcheck

Or package.yaml for Stack:

tests:
  myproject-test:
    dependencies:
      - QuickCheck
      - hspec
      - hspec-quickcheck

The quickCheck Function and Properties

A QuickCheck property is any function that returns Bool, Property, or a type satisfying the Testable typeclass. The simplest properties return Bool:

import Test.QuickCheck

-- Run directly
prop_reverseReverse :: [Int] -> Bool
prop_reverseReverse xs = reverse (reverse xs) == xs

main :: IO ()
main = quickCheck prop_reverseReverse
-- +++ OK, passed 100 tests.

Use verboseCheck to see the generated inputs:

main = verboseCheck prop_reverseReverse
-- Passed:
-- []
-- [0]
-- [-1,1]
-- [2,-3,0,1]
-- ... (100 tests total)

Basic Properties: Commutativity, Identity, Roundtrip

The most useful properties express mathematical laws that your code should obey.

Commutativity — the order of arguments doesn't matter:

prop_addCommutative :: Int -> Int -> Bool
prop_addCommutative x y = x + y == y + x

prop_intersectCommutative :: [Int] -> [Int] -> Bool
prop_intersectCommutative xs ys =
  sort (intersect xs ys) == sort (intersect ys xs)

Identity — applying an operation with a neutral element returns the input unchanged:

prop_appendEmptyRight :: [Int] -> Bool
prop_appendEmptyRight xs = xs ++ [] == xs

prop_addZero :: Int -> Bool
prop_addZero n = n + 0 == n

Roundtrip — encoding and decoding returns the original:

import Data.Aeson (encode, decode)
import Data.Maybe (fromJust)

prop_jsonRoundtrip :: User -> Bool
prop_jsonRoundtrip user =
  fromJust (decode (encode user)) == user

Roundtrip properties are among the most valuable. If serialization, parsing, or encoding has any bug, a roundtrip property will find it.

Idempotence — applying an operation twice gives the same result as once:

prop_sortIdempotent :: [Int] -> Bool
prop_sortIdempotent xs = sort (sort xs) == sort xs

prop_deduplicateIdempotent :: [Int] -> Bool
prop_deduplicateIdempotent xs = nub (nub xs) == nub xs

The forAll Combinator

Sometimes you need more control over what values are generated. The forAll combinator lets you supply an explicit generator:

import Test.QuickCheck

-- Only test with positive integers
prop_sqrtNonNegative :: Property
prop_sqrtNonNegative =
  forAll (choose (0, 1000000)) $ \n ->
    sqrt (fromIntegral n :: Double) >= 0

-- Only test with non-empty lists
prop_headInList :: Property
prop_headInList =
  forAll (listOf1 arbitrary) $ \(xs :: [Int]) ->
    head xs `elem` xs

forAll takes a Gen a (a generator) and a function from a to something Testable. The generator controls what values are produced.

The Arbitrary Typeclass

QuickCheck uses the Arbitrary typeclass to know how to generate values of a type. Built-in instances exist for Int, Double, Bool, Char, String, [], Maybe, Either, tuples, and many more.

For your own types, you define an instance:

data Priority = Low | Medium | High deriving (Eq, Show)

instance Arbitrary Priority where
  arbitrary = elements [Low, Medium, High]

data Task = Task
  { taskId       :: Int
  , taskTitle    :: String
  , taskPriority :: Priority
  , taskDone     :: Bool
  } deriving (Eq, Show)

instance Arbitrary Task where
  arbitrary = Task
    <$> arbitrary          -- Int
    <*> listOf1 letter     -- non-empty String of letters
    <*> arbitrary          -- Priority (uses instance above)
    <*> arbitrary          -- Bool
    where
      letter = elements (['a'..'z'] ++ ['A'..'Z'])

Now QuickCheck knows how to generate Task values automatically:

prop_taskTitleNonEmpty :: Task -> Bool
prop_taskTitleNonEmpty task = not (null (taskTitle task))
-- This always passes because our generator produces non-empty titles

The Gen Monad

Gen is a monad for building generators. The key combinators:

-- Choose a value from a range (inclusive)
choose (1, 100) :: Gen Int
choose ('a', 'z') :: Gen Char

-- Pick uniformly from a list
elements [True, False] :: Gen Bool
elements ["red", "green", "blue"] :: Gen String

-- Generate a list using another generator
listOf arbitrary :: Gen [Int]      -- possibly empty
listOf1 arbitrary :: Gen [Int]     -- at least one element
vectorOf 5 arbitrary :: Gen [Int]  -- exactly 5 elements

-- Apply with weighted frequencies
frequency
  [ (3, return Nothing)
  , (7, fmap Just arbitrary)
  ] :: Gen (Maybe Int)

-- Scale the size parameter
scale (*2) arbitrary :: Gen [Int]  -- larger lists

-- Combine generators
do
  n <- choose (1, 10)
  xs <- vectorOf n arbitrary
  return xs :: Gen [Int]

A realistic generator for a domain type:

data EmailAddress = Email String String  -- local, domain

genEmail :: Gen EmailAddress
genEmail = do
  localLen <- choose (3, 15)
  local    <- vectorOf localLen (elements validLocalChars)
  domainLen <- choose (3, 10)
  domain   <- vectorOf domainLen (elements ['a'..'z'])
  tld      <- elements ["com", "org", "net", "io"]
  return $ Email local (domain <> "." <> tld)
  where
    validLocalChars = ['a'..'z'] ++ ['0'..'9'] ++ "._+-"

Shrinking

When QuickCheck finds a failing input, it tries to shrink it — find a smaller input that still fails. This is one of QuickCheck's most valuable features. Instead of debugging a 50-element list, you get the minimal 2-element list that triggers the bug.

The Arbitrary typeclass includes shrink:

class Arbitrary a where
  arbitrary :: Gen a
  shrink :: a -> [a]  -- return smaller versions of the input

Default shrink returns [] (no shrinking). For your types, implement shrinking to make failures easier to diagnose:

instance Arbitrary Task where
  arbitrary = Task
    <$> arbitrary
    <*> listOf1 letter
    <*> arbitrary
    <*> arbitrary
    where letter = elements ['a'..'z']

  shrink (Task tid title priority done) =
    -- Try smaller task IDs
    [ Task tid' title priority done
    | tid' <- shrink tid
    ] ++
    -- Try shorter titles (keeping non-empty)
    [ Task tid title' priority done
    | title' <- shrink title
    , not (null title')
    ] ++
    -- Try simpler priorities
    [ Task tid title priority' done
    | priority' <- shrink priority
    ]

instance Arbitrary Priority where
  arbitrary = elements [Low, Medium, High]
  shrink High   = [Low, Medium]
  shrink Medium = [Low]
  shrink Low    = []

Conditional Properties with ==>

Sometimes a property only makes sense under certain preconditions. Use the ==> operator:

import Test.QuickCheck

-- Division is only defined for non-zero divisors
prop_divisionCorrect :: Int -> Int -> Property
prop_divisionCorrect n d =
  d /= 0 ==>
    (n `div` d) * d + (n `mod` d) == n

-- Head of a sorted list is the minimum
prop_headIsMinimum :: [Int] -> Property
prop_headIsMinimum xs =
  not (null xs) ==>
    head (sort xs) == minimum xs

Be cautious with ==> — if the precondition is rarely satisfied, QuickCheck may give up after too many discards:

*** Gave up! Passed only 43 tests; 1000 discarded tests.

When this happens, use forAll with a targeted generator instead:

-- Better: generate non-zero divisors directly
prop_divisionCorrect :: Int -> Property
prop_divisionCorrect n =
  forAll (arbitrary `suchThat` (/= 0)) $ \d ->
    (n `div` d) * d + (n `mod` d) == n

HSpec Integration

HSpec's hspec-quickcheck package provides the prop function for seamless integration:

import Test.Hspec
import Test.Hspec.QuickCheck
import Test.QuickCheck

spec :: Spec
spec = do
  describe "Data.List.sort" $ do
    it "sorts a known list" $
      sort [3, 1, 4, 1, 5] `shouldBe` [1, 1, 3, 4, 5]

    prop "produces sorted output" $ \(xs :: [Int]) ->
      isSorted (sort xs)

    prop "is idempotent" $ \(xs :: [Int]) ->
      sort (sort xs) == sort xs

    prop "preserves all elements" $ \(xs :: [Int]) ->
      sort xs `shouldMatchList` xs

  describe "Data.List.nub" $ do
    prop "removes duplicates" $ \(xs :: [Int]) ->
      let deduped = nub xs
      in length (nub deduped) == length deduped

    prop "preserves at least one of each element" $ \(xs :: [Int]) ->
      all (`elem` nub xs) xs

You can configure the number of test cases per property:

import Test.Hspec.QuickCheck (modifyMaxSuccess)

spec :: Spec
spec = do
  modifyMaxSuccess (const 500) $ do
    prop "runs 500 tests instead of 100" $ \(n :: Int) ->
      n + 0 == n

Real Examples: Testing a Sorting Algorithm, Parser, and JSON Roundtrip

Sorting algorithm — mergesort:

mergesort :: Ord a => [a] -> [a]
mergesort [] = []
mergesort [x] = [x]
mergesort xs =
  let (left, right) = splitAt (length xs `div` 2) xs
  in merge (mergesort left) (mergesort right)

merge :: Ord a => [a] -> [a] -> [a]
merge [] ys = ys
merge xs [] = xs
merge (x:xs) (y:ys)
  | x <= y    = x : merge xs (y:ys)
  | otherwise = y : merge (x:xs) ys

-- Properties
prop_mergesortSorted :: [Int] -> Bool
prop_mergesortSorted xs = isSorted (mergesort xs)

prop_mergesortPermutation :: [Int] -> Bool
prop_mergesortPermutation xs = mergesort xs `sameElements` xs

prop_mergesortMatchesStdlib :: [Int] -> Bool
prop_mergesortMatchesStdlib xs = mergesort xs == sort xs

sameElements :: Ord a => [a] -> [a] -> Bool
sameElements xs ys = sort xs == sort ys

isSorted :: Ord a => [a] -> Bool
isSorted []       = True
isSorted [_]      = True
isSorted (x:y:zs) = x <= y && isSorted (y:zs)

Parser roundtrip:

import Data.Csv (encode, decode, HasHeader(..))
import qualified Data.ByteString.Lazy as BL

data Record = Record
  { recId    :: Int
  , recName  :: String
  , recScore :: Double
  } deriving (Eq, Show, Generic)

instance Arbitrary Record where
  arbitrary = Record
    <$> choose (1, 9999)
    <*> listOf1 (elements ['A'..'Z'])
    <*> choose (0.0, 100.0)

prop_csvRoundtrip :: [Record] -> Property
prop_csvRoundtrip records =
  not (null records) ==>
    case decode HasHeader (encode records) of
      Right decoded -> decoded == records
      Left err      -> counterexample ("Decode failed: " <> err) False

The counterexample function adds context to failure messages, showing what went wrong alongside the failing input.

When Properties Beat Example Tests

Use property-based testing when:

  • Your function has mathematical laws — commutativity, associativity, identity, idempotence
  • You serialize and deserialize data — any encode/decode or parse/render pair
  • You implement a well-known algorithm — sorting, searching, graph traversal — and can cross-check against a reference implementation
  • Your function handles collections — length, membership, ordering invariants are easy to state as properties
  • Edge cases are hard to enumerate — empty inputs, boundary values, large inputs are generated automatically

Stick with example-based tests when:

  • Exact output matters — rendering HTML, generating reports, formatting output where you need character-level correctness
  • The property is hard to express abstractly — complex business rules where the property is the example
  • You're testing integration — real databases, real HTTP endpoints, real file systems where generating arbitrary inputs is impractical

The best test suites combine both: HSpec it blocks for concrete expected behaviors, prop blocks for invariants that should hold universally.

Running QuickCheck Tests

# Run all tests
cabal <span class="hljs-built_in">test

<span class="hljs-comment"># Run with more verbose output
cabal <span class="hljs-built_in">test --test-options=<span class="hljs-string">"--verbose"

<span class="hljs-comment"># Run a specific property from ghci
ghci> import Test.QuickCheck
ghci> quickCheck prop_sortIdempotent
+++ OK, passed 100 tests.

ghci> -- Run 1000 <span class="hljs-built_in">times
ghci> quickCheckWith stdArgs { maxSuccess = 1000 } prop_sortIdempotent
+++ OK, passed 1000 tests.

ghci> -- Reproduce a failure with a specific seed
ghci> quickCheckWith stdArgs { replay = Just (mkQCGen 42, 0) } prop_sortIdempotent

Fixing a seed is essential for CI reproducibility when debugging a flaky property failure.

HelpMeTest extends your Haskell testing with production monitoring and AI test generation — start free at helpmetest.com

Read more