Alternatives

An overview of property-based testing libraries available for OCaml and how Hedgehog compares.

QCheck

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:

Tradeoffs:

Jane Street Quickcheck

base_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:

Key differences from Hedgehog

Separate generators and shrinkers

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.

PPX derivation

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.

Shrink sequences vs shrink trees

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.

Observers for function generation

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 ecosystem dependency

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.

Expect_test integration vs algebraic effects

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.

No state machine testing

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.

Subterm combinators

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.

Golden ratio size scaling

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.

Hedgehog

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:

Tradeoffs:

Key differences in depth

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.

Integrated shrinking via rose trees

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.

Recursive tree binding

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.

Tree.interleave for list shrinking

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-controlled generation

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.

Algebraic effects for assertions

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.

Coverage checking

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.

State machine testing

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.

LCS-based diff for counterexamples

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.

Pure shrink strategies

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 comparison

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

Choosing a library