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 re-derived 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, int_lt)
    let _ = assert (List.length(sorted) == List.length(xs))
    let _ = assert (is_sorted(sorted))
    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, 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}