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-quickcheckOr package.yaml for Stack:
tests:
myproject-test:
dependencies:
- QuickCheck
- hspec
- hspec-quickcheckThe 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 == nRoundtrip — 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)) == userRoundtrip 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 xsThe 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` xsforAll 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 titlesThe 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 inputDefault 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 xsBe 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) == nHSpec 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) xsYou 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 == nReal 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) FalseThe 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_sortIdempotentFixing 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