QuickCheck: Property-Based Testing for Haskell and Beyond

QuickCheck: Property-Based Testing for Haskell and Beyond

QuickCheck is the original property-based testing library, born in Haskell and widely imitated across dozens of languages. Instead of writing individual test cases, you describe invariants your code must always satisfy, and QuickCheck generates hundreds of random inputs to try to break them. When it finds a failing case, it automatically shrinks it to the minimal reproducing example.

Key Takeaways

Properties replace hand-crafted examples. Rather than listing specific inputs and expected outputs, you declare what must always be true — for instance, sorting a list twice should equal sorting it once.

Generators produce structured random data. QuickCheck's Arbitrary typeclass and combinators like choose, elements, and listOf let you build generators for any data shape your code consumes.

Shrinking gives you minimal failing cases. When a property fails on a large, complex input, QuickCheck automatically reduces it to the smallest input that still fails — making debugging far faster.

forAll gives you explicit generator control. When the default Arbitrary instance isn't right for a test, forAll myGen property lets you supply your own generator inline.

QuickCheck influenced every major language. Hypothesis (Python), fast-check (JS), PropEr (Erlang), and jqwik (Java) all trace their lineage directly to QuickCheck's design.

What Is QuickCheck?

QuickCheck was created by Koen Claessen and John Hughes at Chalmers University in 1999. The core idea was radical at the time: instead of the developer specifying test inputs, the testing library would generate them automatically. The developer's job was to write properties — statements that must hold for all inputs — and the library's job was to try to falsify them.

After more than two decades, that idea has proven so powerful that virtually every modern language has a QuickCheck port or equivalent.

The library ships with GHC's standard tooling and is available on Hackage as QuickCheck. Add it to your package.yaml or cabal file:

# package.yaml (hpack)
dependencies:
  - QuickCheck >= 2.14
  - hspec

Writing Your First Property

Here is the classic example: a sort function should be idempotent.

import Test.QuickCheck
import Data.List (sort)

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

Running it:

ghci> quickCheck prop_sortIdempotent
+++ OK, passed 100 tests.

QuickCheck generated 100 random [Int] values, applied both sides of the equation, and found no counterexample. The [Int] type annotation is all that is needed — QuickCheck knows how to generate lists of integers because both List and Int have Arbitrary instances.

A more complete property for sort verifies ordering:

prop_sortOrdered :: [Int] -> Bool
prop_sortOrdered xs =
  let sorted = sort xs
  in  all (\(a, b) -> a <= b) (zip sorted (tail sorted))
      || length sorted <= 1

The Arbitrary Typeclass

Arbitrary is the heart of QuickCheck. Any type that implements it can be used as a generated input. The typeclass has two methods:

class Arbitrary a where
  arbitrary :: Gen a
  shrink    :: a -> [a]   -- default: returns []

arbitrary is a generator — a value of type Gen a that QuickCheck's engine can run to produce random samples. shrink takes a failing value and returns smaller candidates to try.

The library ships with instances for all primitive types, lists, tuples, Maybe, Either, and many more. For your own types:

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

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

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

instance Arbitrary Task where
  arbitrary = Task
    <$> choose (1, 10000)
    <*> arbitrary
    <*> arbitrary
  shrink (Task i p d) =
    [ Task i' p d | i' <- shrink i ]

Now any property that takes a Task will get random tasks automatically.

Core Generator Combinators

QuickCheck provides a rich library of combinators to build generators for complex shapes.

choose — pick uniformly from a range:

gen_smallInt :: Gen Int
gen_smallInt = choose (1, 100)

elements — pick uniformly from a list:

gen_status :: Gen String
gen_status = elements ["open", "closed", "pending", "archived"]

oneof — pick uniformly from a list of generators:

gen_shape :: Gen Shape
gen_shape = oneof
  [ Circle  <$> choose (1.0, 100.0)
  , Rect    <$> choose (1.0, 50.0) <*> choose (1.0, 50.0)
  ]

frequency — weighted selection:

gen_biasedBool :: Gen Bool
gen_biasedBool = frequency [(9, return True), (1, return False)]

listOf, listOf1, vectorOf:

gen_nonEmptyList :: Gen [Int]
gen_nonEmptyList = listOf1 arbitrary

gen_fixedList :: Gen [Int]
gen_fixedList = vectorOf 5 arbitrary

suchThat — filter (use sparingly; high rejection rates slow tests):

gen_positiveInt :: Gen Int
gen_positiveInt = arbitrary `suchThat` (> 0)
-- Better: use Positive wrapper
gen_positiveInt' :: Gen Int
gen_positiveInt' = getPositive <$> arbitrary

Using forAll for Explicit Generators

When the default Arbitrary instance produces values that don't fit a test's preconditions, forAll lets you specify the generator inline without writing a new instance:

prop_divisionByNonZero :: Property
prop_divisionByNonZero =
  forAll (arbitrary `suchThat` (/= 0)) $ \divisor ->
  forAll arbitrary $ \dividend ->
    dividend `div` divisor == dividend `div` divisor  -- trivial, but shows the pattern

A more realistic example — testing a function that processes non-empty strings:

prop_uppercaseRoundtrip :: Property
prop_uppercaseRoundtrip =
  forAll (listOf1 (elements ['a'..'z'])) $ \s ->
    map toLower (map toUpper s) == s

Shrinking in Depth

Shrinking is what makes QuickCheck practical. Without it, a failing test on a 500-element list tells you almost nothing. With it, QuickCheck automatically reduces the failing input to the smallest case that still fails.

Here is a deliberately broken function:

-- Bug: crashes when list contains 42
mySum :: [Int] -> Int
mySum [] = 0
mySum (x:xs)
  | x == 42   = error "cannot handle 42"
  | otherwise = x + mySum xs
prop_mySumMatchesStdlib :: [Int] -> Bool
prop_mySumMatchesStdlib xs = mySum xs == sum xs

Without shrinking, QuickCheck might report a failure on [17, -3, 99, 42, 0, 5, ...]. With shrinking (which [Int] supports by default), it will reduce the counterexample to [42] — the minimal failing case.

To add shrinking to your own types, implement the shrink method to return structurally simpler values:

shrink (Task i p d) =
  [ Task i' p d | i' <- shrink i ] ++
  [ Task i p' d | p' <- shrink p ] ++
  [ Task i p d' | d' <- shrink d ]

Labeling and Classifying Tests

Use label, classify, and collect to understand the distribution of generated inputs:

prop_sortLengthPreserved :: [Int] -> Property
prop_sortLengthPreserved xs =
  classify (null xs)         "empty" $
  classify (length xs == 1)  "singleton" $
  classify (length xs > 10)  "large" $
  length (sort xs) === length xs

Output:

+++ OK, passed 100 tests:
 7% empty
 8% singleton
12% large
73% other

This helps you notice if your generators are skewing toward edge cases (or away from them).

Integrating with Hspec and Tasty

QuickCheck integrates cleanly with both major Haskell test frameworks.

Hspec:

import Test.Hspec
import Test.Hspec.QuickCheck

spec :: Spec
spec = do
  describe "sort" $ do
    prop "is idempotent"          prop_sortIdempotent
    prop "preserves length"       prop_sortLengthPreserved
    prop "produces ordered output" prop_sortOrdered

Tasty:

import Test.Tasty
import Test.Tasty.QuickCheck as QC

tests :: TestTree
tests = testGroup "Sort properties"
  [ QC.testProperty "idempotent"        prop_sortIdempotent
  , QC.testProperty "preserves length"  prop_sortLengthPreserved
  ]

Configuring QuickCheck

The default of 100 tests is conservative. For CI you often want more:

myArgs :: Args
myArgs = stdArgs { maxSuccess = 10000, maxSize = 200 }

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

main :: IO ()
main = quickCheckWith myArgs prop_withMoreTests

With Hspec, use modifyMaxSuccess:

spec :: Spec
spec = modifyMaxSuccess (const 500) $ do
  prop "idempotent" prop_sortIdempotent

Beyond Haskell: The QuickCheck Family

QuickCheck's ideas have spread across the industry:

Language Library Notes
Python Hypothesis Stateful testing, database integration
JavaScript/TS fast-check Full TypeScript support, model-based testing
Erlang/Elixir PropEr Statem for stateful systems
Java jqwik JUnit 5 integration
Scala ScalaCheck Integrates with ScalaTest, Specs2
Rust proptest Macro-driven, deterministic shrinking
Go gopter Pure Go, no CGo

If you are writing Haskell today, use the original. If you are on another platform, you have excellent options — covered in companion posts on Hypothesis and fast-check.

Complementing QuickCheck with End-to-End Testing

QuickCheck excels at testing pure functions and algorithmic invariants. It is less suited for testing browser flows, REST APIs with authentication, or multi-step user journeys — scenarios where the environment matters as much as the logic.

HelpMeTest fills that gap. It runs AI-powered end-to-end tests against your live application, verifying that the properties your QuickCheck tests prove in isolation also hold when the full stack is assembled. A common pattern is to use QuickCheck for data-layer invariants and HelpMeTest for the user-facing flows that depend on them. The two tools are complementary: QuickCheck gives you fast, exhaustive property coverage at the unit level; HelpMeTest gives you confidence that those properties survive integration.

Summary

QuickCheck introduced an idea that reshaped how developers think about testing: rather than constructing examples, declare invariants and let the machine search for counterexamples. Its generator combinators, Arbitrary typeclass, and automatic shrinking form a model that every subsequent property-based testing library has followed. If you are writing Haskell, QuickCheck is the natural starting point; if you are on another platform, the concept transfers directly to Hypothesis, fast-check, or PropEr.

Read more

Start now free