An overview of property-based testing libraries available for OCaml and how Hedgehog compares.
QCheck is the most established property-based testing library for OCaml, with a large ecosystem and integrations with Alcotest and OUnit.
Approach: qcheck-core ships two APIs side by side. The original QCheck module follows the traditional QuickCheck model, keeping generation (QCheck.Gen.t) and shrinking (QCheck.Shrink.t = 'a -> 'a Iter.t) separate. QCheck2, introduced in 0.18 (2021), uses integrated shrinking: QCheck2.Gen.t = Random.State.t -> 'a Tree.t, where Tree.t is a rose tree with lazy Seq.t children - the same design as Hedgehog.Tree.
Strengths:
ppx_deriving_qcheckqcheck-stm and qcheck-linTradeoffs:
QCheck API still needs manual shrink functions, and its shrinking can produce values outside the generator's domainbase_quickcheck is part of the Jane Street ecosystem; the ppx_quickcheck deriver lives in the same repository.
Approach: Uses a "Quickcheckable" type class pattern. Generators and observers are derived via ppx for types annotated with [@@deriving quickcheck].
Strengths:
Tradeoffs:
Jane Street Quickcheck keeps Generator.t and Shrinker.t as separate types. Shrinkers have type 'a -> 'a Sequence.t, produced independently of the generator that built the value. Hedgehog's rose tree approach (Tree.t with lazy Seq.t children) means shrinking is integrated into generation: composed generators (let*) automatically compose their shrinking. In Quickcheck, you must ensure shrinkers are properly derived for composite types, typically via ppx.
Quickcheck's strongest feature is [%quickcheck.generator: int list] — automatic generator derivation via ppx for any type annotated with [\@\@deriving quickcheck]. Hedgehog has no ppx support, all generators are written explicitly. This is more boilerplate but fully transparent, you always know exactly what your generator does.
Quickcheck's Shrinker.t = 'a -> 'a Sequence.t is lazy, but it is a flat sequence of candidates for one value. Hedgehog's Tree.t children are lazy Seq.t sequences of subtrees, so each candidate carries its own further shrinks. That is what lets composition work automatically and lets a strategy like Shrink.towards be infinite without ever being materialised.
Quickcheck provides Observer.t for generating random functions — a feature Hedgehog does not have. Observers allow you to test higher-order properties like List.map f (List.map g xs) = List.map (fun x -> f (g x)) xs with randomly generated f and g.
base_quickcheck pulls in base, ppxlib, splittable_random and several ppx_* libraries, and its current release requires OCaml >= 5.1. Hedgehog adds only domainslib (used by Hedgehog.Property.check_parallel) on top of the stdlib, and qcheck-core only unix. Both are easier to adopt in projects that don't already use the Jane Street ecosystem.
Quickcheck integrates deeply with Expect_test, test output is compared against expected output inline in source files. Hedgehog uses OCaml 5 algebraic effects for assertions, annotations, and coverage tracking. The effects-based approach allows collecting rich counterexample context (annotate, cover, classify) alongside failures without exception handling.
Quickcheck is purely a generator/shrinker library with no built-in state machine testing. Hedgehog's Hedgehog.Stm module provides both sequential and parallel linearizability checking out of the box.
Hedgehog provides Gen.subterm, subterm2, and subterm3 for recursive data structures. When shrinking f x y, these try x and y directly as candidates before trying f x' y or f x y'. This dramatically speeds up shrinking for ASTs and recursive structures. Quickcheck has no equivalent, you must manually arrange your shrinker to try subterms first.
Hedgehog's Gen.recursive reduces the size parameter by the golden ratio (0.618) at each recursion level to naturally control depth. Quickcheck threads a size parameter but does not have this specific mathematical scaling strategy.
Approach: Integrated shrinking via rose trees. Generators produce Hedgehog.Tree.t values where the root is the generated value and children are shrunk alternatives.
Strengths:
Hedgehog.Stm)Tradeoffs:
QCheck2 and Hedgehog share the same foundation, integrated shrinking over lazy rose trees, driven by a splittable random state, so most of what follows is about what each library builds on top. Where a comparison holds only for QCheck's original API, this is called out explicitly.
The core type is Gen.t = int -> Seed.t -> 'a Tree.t option. Every generator produces a rose tree where the root is the generated value and children are shrunk alternatives. The option lets a generator discard a value (via Hedgehog.Gen.filter, say) rather than fail the test. QCheck2 takes the same approach. The original QCheck API does not: there, generation and shrinking are separate, so shrinking can produce values that violate generator invariants - a filtered generator might shrink to a value that no longer passes the filter.
Gen.bind recursively binds through the entire shrink tree, not just the root value. This matches the semantics of Haskell Hedgehog's TreeT monad: when you compose generators with let*, shrinking the first generator automatically re-runs the second generator on each shrunk alternative. QCheck2's Tree.bind does the same. The original QCheck API has no equivalent, composed generators there lose shrinking information.
List shrinking uses an interleaving algorithm that combines multiple strategies: removing elements (halving, then individual removal), shrinking individual elements in place, and shrinking pairs of elements simultaneously. The entire process uses lazy Seq.t sequences to avoid materialising the (potentially infinite) shrink tree. QCheck2 builds its list shrink trees from a comparable halve-then-drop strategy.
Range.t is a first-class value packaging an origin together with size-dependent bounds, and every numeric generator takes one — so Range.linear 0 100 scales with the size parameter and shrinks toward 0 wherever it appears. QCheck2 expresses these ideas separately: Gen.int_range ?origin sets a shrink target for one generator and Gen.sized threads a size parameter, but there is no reusable abstraction combining the two, and no linear or exponential scaling strategy built in.
Property.assert_, annotate, cover, classify and collect all use Effect.perform under the hood. This keeps test logic cleanly separated from generators — you write return (fun () -> assert_ ...; annotate ...) rather than threading assertion results through the generator monad. QCheck has no equivalent, test outcomes are returned as values.
Properties can require minimum coverage of labelled categories: cover 50.0 "positive" (n > 0) fails the property if fewer than 50% of test cases satisfy the condition. Coverage is implemented via effect-based log accumulation in the property runner. QCheck reports distributions through Test.make's ?collect and ?stats arguments, but cannot fail a test for missing a threshold.
The Hedgehog.Stm module supports both sequential and parallel (linearizability) testing. Parallel testing generates command sequences, runs them concurrently with Domain.spawn, then checks whether the observed results match some sequential interleaving of the model. QCheck offers the same capability through the separate qcheck-stm and qcheck-lin packages, which pioneered this style of testing for OCaml 5 and are what Hedgehog.Stm takes its cues from, the difference is that Hedgehog's lives in the library itself.
When === or diff assertions fail, a line-level LCS diff algorithm produces readable output showing exactly what changed between expected and actual values. QCheck prints the counterexample value but does not compute diffs.
Shrink.towards, Shrink.halves, and Shrink.removes are composable building blocks that produce lazy sequences of shrunk alternatives. These integrate directly with the rose tree structure so that all generators shrink for free. QCheck2 provides comparable primitives (Shrink.int_towards, Shrink.number_towards and friends, also returning Seq.t); in the original QCheck API these must be composed into Shrink.t values and attached to each generator by hand.
Feature | QCheck | JS Quickcheck | Hedgehog |
|---|---|---|---|
Integrated shrinking | Yes (QCheck2) | No | Yes |
Lazy shrink trees | Yes (QCheck2) | No (flat Sequence.t) | Yes (Seq.t) |
Splittable seeds | Yes | Yes | Yes |
Reusable range type | No (?origin, sized) | No | Yes (Range.t) |
PPX derivation | Yes (ppx_deriving_qcheck) | Yes | No |
Function generation | Yes (Observable) | Yes (Observer.t) | No |
Subterm combinators | No | No | Yes |
State machine testing | Yes (qcheck-stm) | No | Yes (in library) |
Linearizability testing | Yes (qcheck-lin) | No | Yes (in library) |
Parallel property runner | No | No | Yes (domainslib) |
Coverage enforcement | No (collect/stats only) | No | Yes |
Effects-based API | No | No | Yes |
Diff on failure | No | Via Expect_test | Yes (LCS) |
OCaml < 5 support | Yes (4.08+) | No (current release) | No |
Dependencies | unix | base, ppxlib, ppx_* | domainslib |
Alcotest integration | Separate package | No | Separate package |