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
- hspecWriting 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 xsRunning 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 <= 1The 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 arbitrarysuchThat — 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 <$> arbitraryUsing 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 patternA 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) == sShrinking 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 xsprop_mySumMatchesStdlib :: [Int] -> Bool
prop_mySumMatchesStdlib xs = mySum xs == sum xsWithout 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 xsOutput:
+++ OK, passed 100 tests:
7% empty
8% singleton
12% large
73% otherThis 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_sortOrderedTasty:
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_withMoreTestsWith Hspec, use modifyMaxSuccess:
spec :: Spec
spec = modifyMaxSuccess (const 500) $ do
prop "idempotent" prop_sortIdempotentBeyond 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.