QuickCheck Tutorial Part 2

The Challenge of Constrained Generators
One of the biggest challenges in property-based testing (PBT) is constrained random generation. Real-world inputs are often not something a simple structural generator can handle. We can automatically derive generators for simple types, but once a type has internal invariants—or values must satisfy a predicate—this approach quickly runs out of steam. A naïve idea is: "generate values from a large domain first, then filter out the ones that don’t satisfy the condition inside the property." But that often makes testing extremely inefficient, or even yields no valid samples at all, because valid inputs are typically very sparse.
Consider a classic constrained PBT scenario:
This says: if a tree is a valid binary search tree (BST), then inserting a new value into produces another valid BST.
To test this property, the framework repeatedly samples (x, t). If t is not a BST, the sample is discarded immediately; only samples that meet the precondition will proceed to insert and then check the result. The problem is that the probability of a random tree being a BST is very low, so a naïve generator will waste most test iterations on discards. In such cases, we have to design a specialized generator that directly produces trees satisfying isBST. In this article, we’ll gradually explore how to design custom generators and implement them using QuickCheck’s API, so we can efficiently test constrained properties.
Simple Generators
Let’s start with a class of simple generators—the building blocks of more complex ones. They mainly handle things like restricting the domain of primitive values, mixing container types, combining product types, and so on.
Range Control
In PBT, the commonest starting point is modeling the range of values for primitive types—more precisely, constraining the domain of an ordered type. For integers, we might only care about a certain interval; for characters, we might focus on a specific range or category. In QuickCheck, we have functions such as @qc.int_range, @qc.small_int, @qc.nat, and @qc.neg_int to express different integer domains, as well as @qc.char_range, @qc.alphabet, and @qc.numeral for constraining character domains. In practice, we usually use these generators to restrict inputs to the range allowed by the intended semantics, and then rely on the property to validate higher-level relationships.
///|
test "gen @qc.int_range invariant" {
let gen = @qc.int_range(-10, 10)
let prop = @qc.forall(gen, fn(x) { x >= -10 && x <= 10 })
@qc.quick_check(prop)
}
Generators are not always "random". We can also construct a constant generator with @qc.pure, which is useful for representing boundary cases or fixing certain preconditions. This kind of generator is crucial when composing generators: it lets us hold some inputs steady so we can focus on variation in the rest.
///|
test "gen @qc.pure value" {
let gen = @qc.pure(7)
let prop = @qc.forall(gen, fn(x) { x == 7 })
@qc.quick_check(prop)
}
Deriving Generators with Arbitrary
Arbitrary is an important trait in QuickCheck: it defines how to generate random values for a given type. The MoonBit compiler has built-in support for automatically deriving Arbitrary instances, producing a default random generator for simplest types. You only need to write derive(Arbitrary).
///|
enum Color {
Red
Green
Blue
} derive(Arbitrary, Show)
If a type already has an Arbitrary instance, then @qc.Gen::spawn can produce a generator with the default distribution. This matches the implicit generation logic used by @qc.quick_check_fn, but it also allows us to insert the generator explicitly into @qc.forall. That keeps generator composition structurally clear, and still lets us layer additional constraints on top.
///|
test "gen spawn for arbitrary" {
let gc : @qc.Gen[Color] = @qc.Gen::spawn()
let gen : @qc.Gen[Int] = @qc.Gen::spawn()
inspect(
gc.samples(size=5),
content=(
#|[Green, Green, Green, Blue, Red]
),
)
inspect(
gen.samples(),
content=(
#|[6, 4, -6, -3, 0, 2, -8, 4, 5, 2]
),
)
}
Collections and Multi-Argument Composition
When a task involves collection structures, a basic generator needs to express both "length" and "where elements come from". @qc.Gen::array_with_size generates fixed-length arrays, and @qc.list_with_size constructs lists of a specified length. Fixed length is not just for convenience; it often corresponds directly to preconditions in protocols, formats, or algorithms.
///|
test "gen array_with_size" {
let gen = @qc.int_range(0, 9).array_with_size(5)
json_inspect(gen.samples(size=5), content=[
[0, 6, 4, 3, 8],
[0, 4, 6, 5, 7],
[5, 2, 0, 0, 2],
[4, 1, 0, 4, 3],
[5, 3, 3, 1, 4],
])
}
///|
test "gen @qc.list_with_size sample" {
let gen = @qc.char_range('a', 'f').list_with_size(3)
json_inspect(gen.sample(), content=["a", "b", "f"])
}
Multi-argument functions are the norm in real systems. @qc.tuple, @qc.triple, and @qc.quad let us combine multiple generators into a single input, so we can keep the uniform "single-argument property" execution model. This not only simplifies the property itself, but also allows shrinking to consider interactions between multiple parameters at the same time.
///|
test "gen @qc.tuple for two args" {
let gen = @qc.tuple(@qc.int_range(-20, 20), @qc.int_range(-20, 20))
let prop = @qc.forall(gen, fn(p) {
let (a, b) = p
a - b + b == a
})
@qc.quick_check(prop)
}
The last key step in these building blocks is transformation. @qc.Gen::fmap lets us apply a pure function to certain results, mapping an existing domain into a new one. This capability looks simple, but it’s central to building business-specific inputs; later distribution control and conditional filtering will also be built on top of this layer.
///|
test "gen fmap transform" {
let gen = @qc.int_range(0, 50).fmap(fn(x) { x * 2 })
let prop = @qc.forall(gen, fn(x) { x % 2 == 0 })
@qc.quick_check(prop)
}
With these basic structures, we can already cover the commonest input shapes in real-world testing: constrained numeric, fixed-length collections, and multi-parameter combinations. From here, the next problem is whether our distribution is "reasonable"—that is, how to get closer to real-world data while staying within a controllable design space. That will be the focus of the next section.
Statistical Distribution Control
Once we can generate inputs with "valid shapes", the next question is: do those inputs appear with frequencies that resemble the real world? That’s where distribution control comes in. Real data is often multimodal, skewed, or structurally biased. If we rely on a single range generator, coverage will feel thin. We need composition and weighting to bring the input distribution closer to realistic scenarios, while keeping properties concise.
When the domain has multiple categories or paths, @qc.one_of is the directest combinator. It chooses uniformly among several generators. This is useful for placing boundary samples alongside normal cases, so a property can hit extreme conditions while still covering ordinary variations.
///|
test "gen @qc.one_of mix" {
let gen = @qc.one_of([@qc.pure(0), @qc.pure(1), @qc.int_range(-10, 10)])
let prop = @qc.forall(gen, fn(x) { x >= -10 && x <= 10 })
@qc.quick_check(prop)
}
Uniform choice is often not ideal: real data usually has clear mainstream ranges or "hot" values. In that case, we can use @qc.frequency to weight branches. This lets us express a distribution like "most cases come from one range; a few come from another," concentrating test budget where bugs are likelier, while still keeping coverage of rare paths.
///|
test "gen @qc.frequency weighted" {
let gen = @qc.frequency([
(6, @qc.int_range(-3, 3)),
(1, @qc.int_range(-30, 30)),
])
let prop = @qc.forall(gen, fn(x) { x >= -30 && x <= 30 })
@qc.quick_check(prop)
}
For discrete enumerations, @qc.one_of_array and @qc.one_of_list are more natural: they sample directly from a given set, without requiring overly elaborate generator construction. We often use them to simulate protocol fields, status codes, or configuration values from a fixed set, making properties closer to real inputs.
///|
test "gen @qc.one_of_array enum" {
let methods : Array[String] = ["GET", "POST", "PUT"]
let gen = @qc.one_of_array(methods)
let prop = @qc.forall(gen, fn(m) { methods.contains(m) })
@qc.quick_check(prop)
}
When multiple fields have dependencies, @qc.Gen::bind allows us to encode those dependencies during generation. It lets us generate one value first, then generate subsequent fields based on it—satisfying constraints at the data level and avoiding large stacks of precondition checks inside the property.
bindis a powerful monadic operation. It allows us to dynamically adjust distributions and structure during generation, producing inputs that satisfy complex relationships directly. At the same time, it’s harder to understand and debug, so we should keep the structure layered and clear—avoiding excessive nesting or relying onbindas a catch-all way to express complicated logic.
///|
test "gen bind dependent" {
let gen = @qc.int_range(-10, 10).bind(fn(base) {
@qc.int_range(0, 5).fmap(fn(delta) { (base, base + delta) })
})
let prop = @qc.forall(gen, fn(p) {
let (a, b) = p
a <= b && b - a <= 5
})
@qc.quick_check(prop)
}
We already introduced @qc.Gen::fmap, and it remains a fundamental tool for composition: without changing branch probabilities, it maps generated values into a business-level structure. This mapping preserves the shape of the distribution while making the data better match interface semantics, so it’s commonly used to construct identifiers, normalized inputs, or derived fields.
In practice, we often use @qc.one_of or @qc.frequency to set the "macro distribution", and then use bind and fmap to handle "micro structure" constraints and derivations. This two-level structure balances coverage and realism while keeping generators readable. Composition and distribution do not change the property itself, but they can significantly affect test effectiveness. Distribution design should follow the intended semantics: avoid being overly uniform, and avoid being overly biased, so that random testing can discover defects more reliably under a limited budget.
On top of that, we still need to control size and complexity. That involves how the size parameter evolves and how we scale generators—this will be the focus of the next section.
Size and Complexity
This section discusses how the size parameter affects data size and test complexity. Random testing is not "bigger is always better": inputs that are too large can obscure the essence of a bug, while inputs that are too small may not provide meaningful coverage. We should treat size as a knob that trades off cost and benefit, and use configuration and generator strategies to approximate real complexity within a controllable budget.
@qc.quick_check provides max_size to cap overall size. This is the most direct control mechanism. We often use it when algorithmic complexity is high or the input domain can grow exponentially, to prevent test time from blowing up while still checking the property thoroughly within a reasonable range.
///|
test "@qc.quick_check max_size" {
let gen = @qc.sized(fn(n) { @qc.small_int().list_with_size(n) })
let prop = @qc.forall(gen, fn(xs) { xs.length() >= 0 })
@qc.quick_check(prop, max_size=30)
}
When we want "data structures to grow in sync with size", @qc.sized is the most explicit tool. It passes size into the generation logic, letting us encode size constraints inside the generator and avoid dealing with size-related preconditions in the property. This is especially effective for arrays, lists, trees, and similar structures, because it internalizes complexity control into the construction rules of the input domain.
///|
test "@qc.sized array with explicit length" {
let gen = @qc.sized(fn(n) {
let len = if n < 0 { 0 } else { n }
@qc.tuple(@qc.pure(len), @qc.int_range(0, 9).array_with_size(len))
})
inspect(
gen.sample(),
content=(
#|(100, [5, 5, 0, 2, 0, 5, 4, 6, 4, 2, 1, 3, 3, 3, 0, 8, 2, 4, 2, 3, 5, 6, 5, 8, 8, 6, 2, 1, 7, 3, 6, 6, 1, 3, 8, 3, 4, 7, 4, 8, 7, 4, 0, 7, 2, 5, 4, 6, 5, 5, 8, 8, 5, 6, 5, 6, 2, 3, 5, 7, 3, 3, 0, 3, 7, 4, 0, 4, 0, 7, 6, 6, 2, 5, 1, 5, 3, 3, 2, 7, 8, 8, 8, 1, 4, 2, 8, 0, 8, 8, 4, 2, 6, 5, 0, 2, 5, 2, 0, 6])
),
)
}
When we want to restrict size without changing the generator’s structure, we can use @qc.Gen::resize. It fixes size to a specific value, making complexity stable and predictable. This is often useful during debugging or regression testing, where we want counterexamples to be more concentrated and runtime more consistent.
///|
test "resize clamps size" {
let gen = @qc.sized(fn(n) { @qc.int_range(0, 9).list_with_size(n) })
let small = gen.resize(5)
let prop = @qc.forall(small, fn(xs) { xs.length() == 5 })
@qc.quick_check(prop)
}
If we want size to vary with size but grow less steeply, we can use @qc.Gen::scale to adjust the size mapping. This effectively adds a function on top of the "complexity growth curve", letting input size grow more gradually as test rounds progress, resulting in more stable coverage and more controllable runtime within a limited budget.
///|
test "scale slows growth" {
let gen = @qc.sized(fn(n) { @qc.int_range(0, 9).list_with_size(n) })
let scaled = gen.scale(fn(n) { n / 2 })
let prop = @qc.forall(scaled, fn(xs) { xs.length() <= 20 })
@qc.quick_check(prop, max_size=40)
}
Size control affects not only performance but also failure explanation. Structures that are too large increase shrinking time, add noise to counterexamples, and can even hide the critical path. That’s why we should treat max_size, resize, and scale as one coherent strategy: use different growth curves at different stages so that properties can reach complex cases while keeping failures readable and diagnosable.
Combinator Constructors
At this point we have range constraints, distribution control, and size control. The remaining difficulty is structural constraints. For example, binary search or interval merging typically requires the input array to be sorted—something a basic generator like int_range cannot express directly as a precondition. A straightforward approach is to generate an array first, then use @qc.filter to keep only sorted samples; this is a common entry point to combinator-based construction.
///|
test "combinator sorted array with filter" {
fn is_non_decreasing(xs : Array[Int]) -> Bool {
fn go(i : Int) -> Bool {
if i + 1 >= xs.length() {
true
} else {
xs[i] <= xs[i + 1] && go(i + 1)
}
}
go(0)
}
let base = @qc.int_range(-8, 8).array_with_size(3)
let prop = @qc.forall(base, fn(arr) {
@qc.forall(@qc.one_of_array(arr), fn(x) {
arr[0] <= x && x <= arr[arr.length() - 1]
})
|> @qc.filter(is_non_decreasing(arr))
})
@qc.quick_check(prop, discard_ratio=20)
}
This example has three layers of composition: first, array_with_size fixes the structure; then, nested forall + one_of_array sets up a dependency between an element and its container; finally, filter enforces the "sorted" constraint. The style is intuitive and works well for quickly validating an idea, but it still discards some samples.
When the discard rate is high, it’s usually better to move constraints into the construction phase. QuickCheck already provides @qc.sorted_array, which we can use directly:
///|
test "combinator sorted array constructor" {
let gen = @qc.sorted_array(5, @qc.int_range(-30, 30))
let prop = @qc.forall(gen, fn(arr) {
@qc.forall(@qc.one_of_array(arr), fn(x) {
arr[0] <= x && x <= arr[arr.length() - 1]
})
})
@qc.quick_check(prop)
}
filter is good for expressing temporary preconditions; constructors like sorted_array are better for stable structural invariants. In engineering practice, we usually start with a filter to locate the right property, then gradually replace it with specialized constructors to make tests both readable and efficient.