Property Testing
March includes a built-in property testing library inspired by Hedgehog. Instead of writing individual test cases with specific inputs, you describe properties that should hold for all values of a type, and the library generates hundreds of random inputs to verify them.
When a property fails, the library automatically shrinks the failing input to the smallest counterexample that still fails, without any extra work from you.
Quick start
mod MyTests do
test "addition is commutative" do
Check.all(Gen.tuple2(Gen.int(-100, 100), Gen.int(-100, 100)), fn pair ->
match pair do
(a, b) -> a + b == b + a
end
)
end
end
Run it:
march test my_tests.march
That’s it. Check.all runs the property 100 times with growing input sizes. If it passes, the test passes silently. If it fails, you get a minimal counterexample.
How it works
Generators produce values with built-in shrink trees
Every generator returns not just a value, but a rose tree of shrink candidates. When Gen.int(0, 100) generates 87, the tree looks like:
87
├── 0 (target)
├── 43 (halfway)
├── 65 (three-quarters)
├── 76
├── 81
├── 84
├── 85
└── 86
Each of those nodes has its own children, recursively. This is called integrated shrinking: the shrink strategy is embedded in the generator, not bolted on as a separate step.
Why integrated shrinking matters
When you compose generators with Gen.bind, the shrinking automatically stays coherent. If you generate a list and then an index into that list, shrinking the list won’t produce an invalid index; the index is recomputed from the same random bits as the original.
This is the key advantage over QuickCheck-style property testing, where shrinking is separate and can produce invalid combinations from dependent generators.
Writing properties
A property is a function that takes a generated value and returns Bool:
test "reverse is involutive" do
Check.all(Gen.list(Gen.int(-50, 50)), fn xs ->
List.length(List.reverse(List.reverse(xs))) == List.length(xs)
)
end
Multiple assertions
Use let _ = assert ... to chain multiple assertions. The final expression must be true:
test "sort invariants" do
Check.all(Gen.list(Gen.int(-100, 100)), fn xs ->
let sorted = List.sort_by(xs, fn (a, b) -> a < b)
let _ = assert (List.length(sorted) == List.length(xs))
let _ = assert (List.all(List.zip(sorted, List.drop(sorted, 1)), fn pair ->
match pair do (a, b) -> a <= b end
))
true
)
end
If any assertion fails, the runner catches the failure and shrinks to the minimal input that triggers it.
Crash-catching
Properties that crash (division by zero, match failures, index out of bounds) are also caught and shrunk:
test "safe_divide handles all inputs" do
Check.all(Gen.tuple2(Gen.int(-100, 100), Gen.int(-100, 100)), fn pair ->
match pair do
(a, b) ->
let _ = safe_divide(a, b)
true
end
)
end
If safe_divide panics for some input, the runner finds the minimal crashing input.
Available generators
Primitives
| Generator | Produces | Shrinks toward |
|---|---|---|
Gen.int(lo, hi) |
Integer in [lo, hi] |
0 (or nearest bound) |
Gen.bool() |
true or false |
false |
Gen.float(lo, hi) |
Float in [lo, hi) |
0.0 (or nearest bound) |
Gen.constant(x) |
Always x |
No shrinking |
Gen.element(xs) |
Uniform from list | First element |
Gen.int_sized() |
Integer in [0, size] |
0 |
Strings and characters
| Generator | Produces | Shrinks toward |
|---|---|---|
Gen.ascii_char() |
Char code 32-126 | 'a' (97) |
Gen.lowercase_char() |
Char code 97-122 | 'a' |
Gen.string() |
ASCII string | Shorter, simpler chars |
Gen.lowercase_string() |
Lowercase string | Shorter |
Gen.string_of(char_gen) |
String from custom char generator | Shorter, simpler |
Collections
| Generator | Produces | Shrinks toward |
|---|---|---|
Gen.list(gen) |
List, length [0, size] |
Shorter, simpler elements |
Gen.list_of_size(n, gen) |
List of exactly n |
Simpler elements |
Gen.option(gen) |
None ~25%, Some(x) ~75% |
None first |
Gen.tuple2(ga, gb) |
2-tuple | Each component independently |
Gen.tuple3(ga, gb, gc) |
3-tuple | Each component independently |
Combinators
| Combinator | Purpose |
|---|---|
Gen.map(gen, f) |
Transform values; shrinking inherited |
Gen.filter(gen, pred) |
Rejection sampling; up to 100 attempts |
Gen.bind(gen, f) |
Dependent generators; shrinking stays coherent |
Gen.one_of(gens) |
Uniform choice among generators |
Gen.frequency(pairs) |
Weighted choice: [(3, gen_a), (1, gen_b)] |
Gen.sized(f) |
Access the size hint: fn size -> gen |
Combinators in depth
Gen.map: transform values
-- Generate even numbers
let gen_even = Gen.map(Gen.int(0, 50), fn n -> n * 2)
-- Generate record-like tuples
let gen_point = Gen.map(
Gen.tuple2(Gen.float(-1.0, 1.0), Gen.float(-1.0, 1.0)),
fn pair -> match pair do (x, y) -> (x, y) end
)
Gen.bind: dependent generators
When the second generator depends on the first value:
-- Generate a list and a valid index into it
let gen_list_and_index = Gen.bind(
Gen.filter(Gen.list(Gen.int(0, 100)), fn xs -> List.length(xs) > 0),
fn xs -> Gen.map(Gen.int(0, List.length(xs) - 1), fn i -> (xs, i))
)
Shrinking the list automatically re-derives a valid index.
Gen.string_of: custom string alphabets
-- Hex strings
let hex_char = Gen.element([48, 49, 50, 51, 52, 53, 54, 55,
56, 57, 97, 98, 99, 100, 101, 102])
let gen_hex = Gen.string_of(hex_char)
-- Digit-only strings
let gen_digits = Gen.string_of(Gen.int(48, 57))
Gen.sized: size-dependent generation
The runner grows the size parameter from 0 to 100 across runs. Use Gen.sized to access it:
-- Cap list length at 10 regardless of runner size
let gen_short_list = Gen.sized(fn s ->
let cap = if s > 10 do 10 else s end
Gen.list_of_size(cap, Gen.int(0, 100))
)
Configuration
Check.all_with
Override defaults with Check.all_with:
test "stress test with more runs" do
let config = { Check.default_config() with num_runs: 500, max_size: 200 }
Check.all_with(Gen.list(Gen.int(0, 100)), fn xs ->
List.length(xs) >= 0
, config)
end
Config fields:
| Field | Default | Description |
|---|---|---|
num_runs |
100 | Number of random inputs to test |
seed |
None |
Fixed seed (Some(42)); overrides env/clock |
max_shrink_steps |
1000 | Max shrink attempts before giving up |
max_size |
100 | Maximum size hint passed to generators |
Reproducible seeds
When a property fails, the output includes the seed:
property failed after 23 run(s)
counterexample: 50
returned false
shrunk 3 step(s) from: 94
reproduce with seed: 1234567890
Re-run with the same seed:
march test my_tests.march --seed=1234567890
forge test --seed=1234567890
Skipping property tests
Property tests run hundreds of iterations and can be slow. Skip them for fast feedback:
march test --skip-properties
forge test --skip-properties
All Check.all calls return immediately without running.
Failure output
A failing property produces a report like:
FAIL: "list is always non-empty"
error: panic: property failed after 1 run(s)
counterexample: []
returned false
shrunk 0 step(s) from: []
reproduce with seed: 1776290875
For assertion failures, the assertion diagnostic is included:
FAIL: "values are bounded"
error: panic: property failed after 5 run(s)
counterexample: 10
raised: assert 10 < 10
left: 10
right: 10
shrunk 2 step(s) from: 13
reproduce with seed: 42
For crashes:
FAIL: "no division by zero"
error: panic: property failed after 21 run(s)
counterexample: 0
raised: int_div: division by zero
shrunk 0 step(s) from: 0
reproduce with seed: 99
Property patterns
Algebraic laws
Test mathematical properties that must hold:
-- Commutativity: a + b == b + a
-- Associativity: (a + b) + c == a + (b + c)
-- Identity: a + 0 == a
-- Inverse: a - a == 0
-- Distributivity: a * (b + c) == a*b + a*c
-- Idempotence: sort(sort(xs)) == sort(xs)
-- Involution: reverse(reverse(xs)) == xs
Round-trip properties
Encode and decode should cancel:
test "base64 round-trip" do
Check.all(Gen.lowercase_string(), fn s ->
let encoded = Base64.encode(Bytes.from_string(s))
match Base64.decode(encoded) do
Ok(decoded) -> Bytes.to_string(decoded) == s
Err(_) -> false
end
)
end
Structural invariants
Operations should preserve certain properties:
-- Length preservation: length(map(f, xs)) == length(xs)
-- Monotonicity: length(filter(p, xs)) <= length(xs)
-- Sorted output: is_sorted(sort(xs))
-- Partition: take(n, xs) ++ drop(n, xs) == xs
Model checking
Compare a complex implementation against a simpler reference:
test "BigInt mul matches Int for small values" do
Check.all(Gen.tuple2(Gen.int(-100, 100), Gen.int(-100, 100)), fn pair ->
match pair do (a, b) ->
BigInt.to_string(BigInt.mul(BigInt.from_int(a), BigInt.from_int(b)))
== int_to_string(a * b)
end
)
end
API reference
Gen module
Primitives: int(lo, hi) (bounds required), int_sized, bool, float, constant, element, ascii_char, lowercase_char
Strings: string, lowercase_string, string_of
Collections: list, list_of_size, option, tuple2, tuple3
Combinators: map, filter, bind, one_of, frequency, sized
Tree inspection (advanced): tree_root, tree_children, tree_singleton, tree_map, tree_bind, tree_filter, run
Check module
| Function | Signature | Description |
|---|---|---|
Check.all(gen, prop) |
Generator(a), (a -> Bool) -> Unit |
Run property with defaults |
Check.all_with(gen, prop, config) |
..., CheckConfig -> Unit |
Run with custom config |
Check.default_config() |
-> CheckConfig |
{num_runs=100, seed=None, max_shrink_steps=1000, max_size=100} |