Property-based testing and metamorphic testing combinator library for Gleam.
metamon treats both styles of testing as first-class concepts:
- Property-based testing (PBT): state a single-input predicate and let metamon search the input space for counter-examples.
- Metamorphic testing (MT): state a relation between outputs produced
by two related inputs (e.g.
f(x)andf(reverse(x))) and let metamon search for inputs where the relation breaks.
Every snippet on this page is the body of a pub fn readme_*_test in
test/readme_test.gleam and is checked by
gleam test on every CI run, so the examples cannot drift out of
sync with the API.
- Install
- Quick start
- Property-based testing
- Metamorphic relations
- Round-trip variants
- Generators
- Transforms and relations
- Coverage and annotations
- JSON output
- N-ary metamorphic relations
- Stateful / model-based testing
- Case study: CRDT algebraic laws
- Configuration
- Reading a failure report
- Choosing PBT vs MT vs
assert_morph - Modules
- Compatibility
- Further reading
- Development
- Contributing
- License
gleam add metamon --devRequirements: Gleam 1.15+, Erlang/OTP 27+, Node.js 22+.
See doc/targets.md for target details and the runtime dependency footprint.
The smallest useful test states a metamorphic relation. string.trim
is idempotent — applying it twice gives the same result as applying
it once. metamon ships a template for this exact shape.
import gleam/string
import metamon
import metamon/generator
import metamon/generator/range
pub fn trim_idempotent_test() {
let mr = metamon.idempotency_of(name: "trim_idempotent", of: string.trim)
metamon.forall_morph(
generator.string_ascii(range.constant(0, 16)),
mr,
string.trim,
)
}If string.trim ever stops being idempotent, the test panics with a
named report:
× metamorphic relation `trim_idempotent` failed
test: forall_morph
source: random(seed=..., size=12)
config seed: 1714867200000123
runs: 7 / 100
shrinks: 4
transform: `apply trim_idempotent`
relation: `equal`
source input (shrunk):
" a"
...
metamon.forall runs a single-argument predicate against many
generated inputs:
import metamon
import metamon/generator
import metamon/generator/range
import gleam/list
pub fn reverse_twice_is_identity_test() {
metamon.forall(
generator.list_of(
generator.int(range.constant(0, 9)),
range.constant(0, 5),
),
fn(xs) { list.reverse(list.reverse(xs)) == xs },
)
}When the branch in the predicate hinges on an intermediate value
(f(input)), the plain forall failure report only shows the shrunk
source input, not what the predicate was actually inspecting.
forall_observable lets the predicate return #(observation, holds);
the observation is rendered into the failure report under the label
predicate value:
import metamon
import metamon/generator
import metamon/generator/range
import gleam/string
pub fn parse_round_trip_test() {
metamon.forall_observable(
generator.string_ascii(range.constant(0, 8)),
fn(s) {
let trimmed = string.trim(s)
// `trimmed` is what the property body cares about, so expose it.
#(trimmed, string.length(trimmed) <= string.length(s))
},
)
}This is equivalent to a plain forall plus a manual
annotate.annotate_value("predicate value", trimmed); the helper
saves that one line and makes the intent explicit.
A metamorphic relation says "if you transform the input in this known way, the output should change in this known way." metamon ships templates for the most common shapes.
import metamon
import metamon/generator
import metamon/generator/range
pub fn sort_dedupe_idempotent_test() {
let mr =
metamon.idempotency_of(name: "sort_dedupe_idempotent", of: sort_dedupe)
metamon.forall_morph(
generator.list_of(
generator.int(range.constant(0, 9)),
range.constant(0, 6),
),
mr,
sort_dedupe,
)
}f and inverse should round-trip cleanly. forall_round_trip is
the one-liner — the failure report header is round_trip[<name>] so
it is immediately obvious from the panic which round-trip broke:
import metamon
import metamon/generator
import metamon/generator/range
import gleam/int
pub fn int_string_round_trip_test() {
metamon.forall_round_trip(
gen: generator.int(range.constant(-1000, 1000)),
name: "int_string_round_trip",
encode: int.to_string,
decode: int.parse,
)
}Round-trip is not exposed as an Mr template because the relation
compares the decoded output against the source input, which the
two-point f(source) ⟷ f(transform(source)) shape of an MR cannot
express directly. forall_round_trip wraps forall instead, so the
error reports retain the same shrunk-source rendering.
The function is unaffected by the transformation. list.length is
invariant under reverse:
import metamon
import metamon/generator
import metamon/generator/range
import metamon/transform/list as list_t
import gleam/list
pub fn length_invariant_under_reverse_test() {
let mr =
metamon.invariant_under(
name: "length_invariant_under_reverse",
under: list_t.reverse(),
)
metamon.forall_morph(
generator.list_of(
generator.int(range.constant(0, 9)),
range.constant(0, 8),
),
mr,
list.length,
)
}The output also transforms in a known way. map(g) commutes with
reverse:
import metamon
import metamon/generator
import metamon/generator/range
import metamon/relation
import metamon/transform/list as list_t
import gleam/list
pub fn map_commutes_with_reverse_test() {
let mr =
metamon.equivariant_under(
name: "map_commutes_with_reverse",
input: list_t.reverse(),
output: list_t.reverse(),
relation: relation.equal(),
)
metamon.forall_morph(
generator.list_of(
generator.int(range.constant(0, 9)),
range.constant(0, 6),
),
mr,
fn(xs) { list.map(xs, fn(n) { n * 2 }) },
)
}When the four templates above don't fit, build the MR by hand from a
Transform(a) and a Relation(b):
import metamon
import metamon/generator
import metamon/generator/range
import metamon/relation
import metamon/transform/list as list_t
import gleam/list
pub fn sum_invariant_under_append_zero_test() {
let append_zero = list_t.append(0)
let mr =
metamon.mr(
name: "sum_invariant_under_append_zero",
transform: append_zero,
relation: relation.equal(),
)
metamon.forall_morph(
generator.list_of(
generator.int(range.constant(0, 9)),
range.constant(0, 5),
),
mr,
fn(items) { list.fold(items, 0, fn(acc, n) { acc + n }) },
)
}No generator, just a fixed input. Useful for regression tests of a specific failing case:
import metamon
import metamon/transform/list as list_t
pub fn sum_reverse_regression_test() {
let mr =
metamon.invariant_under(
name: "sum_invariant_under_reverse",
under: list_t.reverse(),
)
metamon.assert_morph([1, 2, 3, 4, 5], mr, list_sum)
}The commutativity_of template builds an MR over the input pair
#(a, a) whose transform swaps the two components:
import metamon
import metamon/generator
import metamon/generator/range
fn add(a: Int, b: Int) -> Int {
a + b
}
pub fn add_commutative_test() {
let mr = metamon.commutativity_of(name: "add_commutative")
metamon.forall_morph(
generator.tuple2(
generator.int(range.constant(-50, 50)),
generator.int(range.constant(-50, 50)),
),
mr,
fn(pair) { add(pair.0, pair.1) },
)
}Each MR is exercised independently and the runner reports all failures, not just the first:
import metamon
import metamon/generator
import metamon/generator/range
import metamon/transform/list as list_t
pub fn sum_multi_mr_test() {
let invariant_under_reverse =
metamon.invariant_under(name: "sum_under_reverse", under: list_t.reverse())
let invariant_under_append_zero =
metamon.invariant_under(
name: "sum_under_append_zero",
under: list_t.append(0),
)
metamon.forall_morphs(
generator.list_of(
generator.int(range.constant(0, 9)),
range.constant(0, 4),
),
[invariant_under_reverse, invariant_under_append_zero],
list_sum,
)
}Tip — encoder / decoder libraries. If your library exposes a paired
encode/decode, a singleforall_round_tripcall exercises a useful first invariant with no per-input handwriting. Drop this intotest/as a starter property:import metamon import metamon/generator import metamon/generator/range pub fn encode_decode_round_trip_test() { metamon.forall_round_trip( gen: generator.bit_array(range.constant(0, 64)), name: "my_codec", encode: my_codec.encode, decode: my_codec.decode, ) }The two variants below (
forall_round_trip_partialandforall_round_trip_under) cover the common shapes a real codec hits — partial encoders that reject some inputs, and decoded forms that compare equal only under a normalising projection.
forall_round_trip requires encode: a -> b and
decode: b -> Result(a, _). Real codec libraries often produce
encode: a -> Result(b, _) (when not every input is valid for the
codec) or have a source type whose decoded form does not compare equal
under structural ==. Two named variants cover both cases without a
hand-rolled shim.
Inputs the encoder rejects are skipped (treated as out of scope, not failures). Useful for codecs with structural preconditions (byte-alignment, version range, hrp / variant constraints, etc.).
import metamon
import metamon/generator
import metamon/generator/range
import gleam/int
pub fn readme_round_trip_partial_test() {
// Stand-in for a real partial encoder: only encodes even integers.
metamon.forall_round_trip_partial(
gen: generator.int(range.constant(-50, 50)),
name: "even_only_round_trip",
encode: fn(n) {
case n % 2 == 0 {
True -> Ok(int.to_string(n))
False -> Error(Nil)
}
},
decode: fn(s) { int.parse(s) },
)
}Pass a Relation(a) instead of structural ==. Useful for opaque
types whose decoded form normalises (multipart Part re-deriving its
name / filename cache, MIME types whose essence lowercases, etc.).
Combine with relation.equivalent_under(via, name) to compare on a
projection.
import metamon
import metamon/generator
import metamon/generator/range
import metamon/relation
import gleam/string
pub fn readme_round_trip_under_test() {
let case_insensitive =
relation.equivalent_under(string.lowercase, "case_insensitive")
metamon.forall_round_trip_under(
gen: generator.string_alpha(range.constant(0, 8)),
name: "case_insensitive_round_trip",
encode: string.lowercase,
decode: fn(s) { Ok(s) },
equality: case_insensitive,
)
}import metamon/generator
import metamon/generator/range
pub fn shortcut_examples() {
let _: generator.Generator(Bool) = generator.bool()
let _: generator.Generator(Int) = generator.non_negative_int()
let _: generator.Generator(Int) = generator.positive_int()
let _: generator.Generator(Int) = generator.negative_int()
let _: generator.Generator(Int) = generator.byte()
let _: generator.Generator(BitArray) = generator.bit_array(range.constant(0, 16))
let _: generator.Generator(BitArray) = generator.bit_array_printable(range.constant(0, 16))
let _: generator.Generator(BitArray) = generator.bit_array_utf8(range.constant(0, 8))
let _: generator.Generator(String) = generator.string_alpha(range.constant(1, 8))
let _: generator.Generator(String) = generator.string_alphanumeric(range.constant(1, 8))
let _: generator.Generator(String) = generator.string_digit(range.constant(1, 4))
let _: generator.Generator(String) = generator.string_printable_ascii(range.constant(0, 16))
Nil
}These wrap generator.int(range.linear(...)) etc. with sensible
default ranges. Reach for the underlying generator.int(...) when you
need different bounds or shrink origins.
For single-character generators (a-zA-Z, 0-9, etc.), see the
ascii_* family in the Modules table:
ascii_lower, ascii_upper, ascii_letter, ascii_digit,
ascii_alphanumeric, ascii_printable. The string_* shortcuts
above wrap each of those with a length range.
bit_array_printable constrains every byte to printable ASCII
(0x20..0x7E) — useful when fuzzing parsers that take BitArray
but expect printable input (HTTP headers, MIME types, etc.).
bit_array_utf8 produces a BitArray that is guaranteed to decode
back to a string; the len argument is the codepoint count, so the
byte length will be larger when the random string contains multi-byte
codepoints.
generator.string_unicode(len) and generator.unicode_codepoint()
produce valid UTF-8 scalar values only. generator.float(lo, hi) is
finite-only. For genuinely-NaN / ±Infinity inputs, use
generator.float_special() or splice the special values via
with_examples(my_float_gen, generator.float_special_edges()). See
doc/limitations.md for the full caveats around
UTF-8 surrogates, Unicode normalisation, and BEAM vs JavaScript float
behaviour.
Use map2 … map8 (and the matching tuple2 … tuple8) for
record-shaped generators. Prefer applicative composition over nested
bind so integrated shrinking applies on every component.
import metamon
import metamon/generator
import metamon/generator/range
pub type User {
User(name: String, age: Int)
}
pub fn user_age_in_bounds_test() {
let user_gen =
generator.map2(
generator.string_ascii(range.constant(1, 8)),
generator.int(range.constant(0, 120)),
User,
)
metamon.forall(user_gen, fn(u: User) { u.age >= 0 && u.age <= 120 })
}import metamon
import metamon/generator
pub fn traffic_light_test() {
let traffic_light =
generator.frequency([
#(3, generator.return("green")),
#(2, generator.return("yellow")),
#(1, generator.return("red")),
])
metamon.forall(traffic_light, fn(colour) {
colour == "green" || colour == "yellow" || colour == "red"
})
}one_of picks uniformly from a list of generators. For the common
case of "pick uniformly from a fixed set of values", element_of
skips the per-value return wrap:
import metamon
import metamon/generator
pub fn extension_is_known_test() {
metamon.forall(
generator.element_of(["html", "json", "png", "pdf"]),
fn(ext) {
ext == "html" || ext == "json" || ext == "png" || ext == "pdf"
},
)
}element_of panics when the list is empty (mirroring one_of([])).
Every value becomes an edge, so the runner tries each one before
sampling.
The runner consumes edges first, before random generation. Use
with_examples to add must-try inputs from past bug reports:
import metamon
import metamon/generator
import metamon/generator/range
import gleam/string
pub fn trim_idempotent_with_examples_test() {
let trim_idempotent =
metamon.idempotency_of(
name: "trim_idempotent_with_examples",
of: string.trim,
)
metamon.forall_morph(
generator.string_ascii(range.constant(0, 8))
|> generator.with_examples(["", " ", " ", "\t\n hi \n\t"]),
trim_idempotent,
string.trim,
)
}recursive(base, step) halves size on each recursion, so it always
terminates. At size = 0 only base is used.
import metamon
import metamon/generator
import metamon/generator/range
pub type Tree {
Leaf(Int)
Node(Tree, Tree)
}
pub fn tree_has_leaves_test() {
let tree_gen =
generator.recursive(
generator.map(generator.int(range.constant(0, 9)), Leaf),
fn(smaller) {
generator.map2(smaller, smaller, Node)
},
)
metamon.forall(tree_gen, fn(t) {
case count_leaves(t) {
n -> n >= 1
}
})
}import metamon/transform
import gleam/string
import gleeunit/should
pub fn lowercase_then_trim_test() {
let normalise =
transform.then(
transform.new("lowercase", string.lowercase),
transform.new("trim", string.trim),
)
should.equal(normalise.apply(" Hello "), "hello")
should.equal(normalise.name, "lowercase |> trim")
}import metamon/relation
import gleeunit/should
pub fn and_combination_test() {
let positive =
relation.new("positive_left", fn(left: Int, _right: Int) { left > 0 })
let nonzero_right =
relation.new("nonzero_right", fn(_left: Int, right: Int) { right != 0 })
let combined = relation.and(positive, nonzero_right)
should.be_true(combined.holds(5, 3))
should.be_false(combined.holds(0, 3))
}relation.or, relation.invert, relation.implies complete the
Boolean set. For the most common domain shapes, four shortcut
combinators skip the and / custom-new plumbing entirely:
import metamon/relation
import gleam/int
import gleeunit/should
pub fn approximately_test() {
// approximately(epsilon): Float equality with a tolerance.
let approx = relation.approximately(0.0001)
should.be_true(approx.holds(0.1 +. 0.2, 0.3))
}
pub fn permutation_of_test() {
// permutation_of: two lists are equal as multisets.
let perm = relation.permutation_of()
should.be_true(perm.holds([3, 1, 2], [1, 2, 3]))
}
pub fn subset_of_test() {
// subset_of: multiset subset — every element of left is matched
// against a *distinct* element of right (so [1, 1] is not a
// subset of [1] because the second 1 has no match left).
let sub = relation.subset_of()
should.be_true(sub.holds([2, 3], [1, 2, 3, 4]))
should.be_false(sub.holds([1, 1], [1]))
should.be_true(sub.holds([1, 1], [1, 1, 2]))
}
pub fn set_subset_of_test() {
// set_subset_of: set subset — every element of left is contained
// somewhere in right, ignoring multiplicity. Reach for this when
// the lists are header-style (presence matters, count does not).
let sub = relation.set_subset_of()
should.be_true(sub.holds([1, 1], [1]))
should.be_true(sub.holds([2, 3], [1, 2, 3, 4]))
should.be_false(sub.holds([1, 4], [1, 2, 3]))
}
pub fn monotone_test() {
// monotone(cmp): holds when cmp(left, right) is Lt or Eq. Useful
// for monotonic-by-construction functions (list.sort, list.scan,
// ...).
let mono = relation.monotone(int.compare)
should.be_true(mono.holds(3, 5))
}import metamon/relation
import gleam/string
import gleeunit/should
pub fn case_insensitive_test() {
let r =
relation.equivalent_under(string.lowercase, "case_insensitive")
should.be_true(r.holds("Hello", "HELLO"))
should.be_false(r.holds("Hello", "World"))
}cover(target, label, condition) asserts that the labelled hits
account for at least target% of all inputs. The property fails even
when every individual run passed if coverage falls short:
import metamon
import metamon/coverage
import metamon/generator
import metamon/generator/range
import gleam/string
pub fn trim_never_grows_input_test() {
metamon.forall(
generator.string_ascii(range.constant(0, 8)),
fn(s) {
coverage.cover(5.0, "non_empty", string.length(s) > 0)
coverage.classify("contains_space", string.contains(s, " "))
string.length(string.trim(s)) <= string.length(s)
},
)
}These are silent on success and surface only on failure, so liberal use is cheap:
import metamon
import metamon/annotate
import metamon/generator
import metamon/generator/range
import gleam/int
pub fn annotated_property_test() {
metamon.forall(
generator.int(range.constant(0, 100)),
fn(n) {
annotate.annotate("currently checking n = " <> int.to_string(n))
annotate.annotate_value("doubled", n * 2)
annotate.footnote("hint: n is non-negative by construction")
n >= 0
},
)
}Set the output format on a per-test config to swap the human-readable text for a single-line JSON object:
import metamon
import metamon/config
import metamon/generator
import metamon/generator/range
pub fn json_output_test() {
let cfg =
metamon.default_config()
|> metamon.with_output_format(config.Json)
metamon.forall_with(
cfg,
generator.int(range.constant(0, 100)),
fn(n) { n >= 0 },
)
}The schema is stable. Top-level keys: mr_name, test_name,
config_seed, runs_done, runs_total, shrinks_done,
shrink_capped, source, morph_mode, relation, source_input,
followup_input, source_output, followup_output, annotations,
footnotes, coverage. Pipe to jq, post to GitHub Actions
annotations, or feed into an LLM analysis step.
When the relation must compare more than two outputs in one shot,
hand forall_morph_n a list of input transforms and a RelationN:
import gleam/list
import metamon
import metamon/generator
import metamon/generator/range
import metamon/relation
import metamon/transform/list as list_t
fn list_sum(items: List(Int)) -> Int {
list.fold(items, 0, fn(acc, n) { acc + n })
}
pub fn sum_under_three_invariants_test() {
metamon.forall_morph_n(
generator.list_of(
generator.int(range.constant(0, 9)),
range.constant(0, 4),
),
[list_t.reverse(), list_t.append(0)],
relation.all_equal(),
list_sum,
)
}relation.all_equal() asserts every output is structurally equal;
relation.pairwise(r) lifts a binary relation to a chain check.
For state machines, build a list of Command(model, real) and run it
against a parallel (model, real) pair:
import gleam/dict
import gleeunit/should
import metamon/command
import metamon/stateful
type Model {
Model(value: Int)
}
type Real {
Real(state: dict.Dict(String, Int))
}
pub fn counter_increments_test() {
let increment =
command.always(
name: "increment",
next_model: fn(m: Model) { Model(value: m.value + 1) },
run: fn(_real: Real) { Ok(Nil) },
)
let initial_model = Model(value: 0)
let initial_real = Real(state: dict.from_list([#("counter", 0)]))
let outcome =
stateful.run(initial_model, initial_real, [increment, increment])
case outcome {
stateful.Passed(final, _, _) -> should.equal(final, Model(value: 2))
stateful.Failed(_, _, _, _) -> should.fail()
}
}command.no_precondition (alias: command.always) skips the
precondition; use command.new to gate commands on the current model.
"No precondition" is not the same as "always runs" — the command's
run step can still return Error(reason), which halts the sequence
and reports Failed. stateful.assert_passed panics with a
structured failure message when that happens. Prefer the
no_precondition name in new code; the always alias is kept for
backward compatibility.
stateful.run requires at least one Command; passing [] is a
programming error (vacuous test) and panics with a structured message.
Use forall(...) if you need a non-stateful property instead.
stateful.assert_passed also panics when every command's
precondition returned False — i.e. the outcome is
Passed(_, ran: 0, skipped: N) with N > 0. The test never compared
model and real, so silently passing would hide precondition or
initial-model bugs. Adjust the preconditions or initial model so at
least one command fires.
CRDTs (conflict-free replicated data types) are characterised by
three laws on their merge operator:
| Law | Statement |
|---|---|
| Idempotency | merge(a, a) == a |
| Commutativity | merge(a, b) == merge(b, a) |
| Associativity | merge(merge(a, b), c) == merge(a, merge(b, c)) |
A G-Counter (grow-only counter) is the simplest CRDT: each replica
holds a per-node counter, and merge takes the per-key max. Here is
the implementation alongside the three laws expressed in metamon:
import gleam/dict.{type Dict}
import gleam/list
import metamon
import metamon/generator
import metamon/generator/range
type GCounter =
Dict(String, Int)
fn merge(left: GCounter, right: GCounter) -> GCounter {
dict.fold(right, left, fn(acc, key, value) {
case dict.get(acc, key) {
Ok(current) if current >= value -> acc
_ -> dict.insert(acc, key, value)
}
})
}
fn make_gcounter(pairs: List(#(String, Int))) -> GCounter {
list.fold(pairs, dict.new(), fn(acc, pair) {
dict.insert(acc, pair.0, pair.1)
})
}
pub fn readme_gcounter_idempotent_test() {
metamon.forall(generator.int(range.constant(0, 100)), fn(seed) {
let counter =
make_gcounter([
#("node-A", seed),
#("node-B", seed * 2),
#("node-C", seed * 3),
])
merge(counter, counter) == counter
})
}
pub fn readme_gcounter_commutative_test() {
metamon.forall(generator.int(range.constant(0, 100)), fn(seed) {
let left = make_gcounter([#("node-A", seed), #("node-B", seed * 2)])
let right = make_gcounter([#("node-A", seed + 1), #("node-C", seed * 3)])
merge(left, right) == merge(right, left)
})
}
pub fn readme_gcounter_associative_test() {
metamon.forall(generator.int(range.constant(0, 100)), fn(seed) {
let a = make_gcounter([#("X", seed), #("Y", seed + 1)])
let b = make_gcounter([#("X", seed + 5), #("Z", seed + 7)])
let c = make_gcounter([#("Y", seed + 9), #("Z", seed + 11)])
merge(merge(a, b), c) == merge(a, merge(b, c))
})
}Three things to note:
forallis the right primitive when the property is a binary or ternary equation.idempotency_ofandcommutativity_ofare metamorphic-relation templates over a unaryf: a -> a, so they do not directly expressmerge(a, a) == aormerge(a, b, c)-style laws. Falling back toforallis the idiomatic way to test n-ary operators.- Per-replica state is generated indirectly via a seed. The
generator produces an
Int, and the test body uses it to derive deterministic counter snapshots. This keeps the generator simple while still exercising the laws across many shapes. - Failure of any one law is a Byzantine-style bug. If associativity fails, replicas that received the same operations in different orders will diverge silently — exactly the class of bug that property-based testing catches and unit testing misses.
The three test functions above are mirrored in
test/readme_test.gleam so the example cannot drift from the actual
API.
Override the defaults via with_* builders. Validation errors return
Result(Config, ConfigError) instead of silently falling back to a
default.
import metamon
import metamon/generator
import metamon/generator/range
pub fn configured_property_test() {
let assert Ok(c) =
metamon.with_runs(
metamon.default_config()
|> metamon.with_seed(metamon.seed(2026)),
30,
)
metamon.forall_with(
c,
generator.int(range.constant(-100, 100)),
fn(n) { n + 0 == n },
)
}with_runs, with_max_size, with_shrink_limit, with_max_edges,
with_regression_file all return Result(Config, ConfigError).
with_seed and with_diff_enabled are total.
Failures are panics whose message is structured for human reading. Every block is optional; only the parts that apply to your test appear:
× metamorphic relation `<mr.name>` failed
test: <gleeunit test name>
source: edge(<i>) | random(seed=<n>, size=<n>)
config seed: <integer>
runs: <i> / <total>
shrinks: <count> | <count>+ (limit reached)
transform: `<input transform name>`
output: `<output transform name>` ; equivariant only
relation: `<relation name>`
source input (shrunk):
<pretty-printed input>
follow-up input (= transform(source)):
<pretty-printed input>
source output:
<pretty-printed output>
follow-up output:
<pretty-printed output>
diff (source_output vs follow-up_output):
<structural diff>
annotations:
- <annotate calls in registration order>
coverage:
<label>: <hits>/<total> (<pct>%) target≥<target>%
footnotes:
- <footnote calls>
reproduce (paste into a test):
// The MR failed for this input. To pin it as a regression,
// call assert_morph with the shrunk input and the same MR.
let input = <pretty-printed shrunk input>
metamon.assert_morph(input, mr, f)
The reproduce block paired with metamon.with_regression_file(...)
gives you two ways to keep failing inputs around:
- Reproduce block (in-test): paste the shrunk input directly into a Gleam test as a literal. Survives regardless of the runner state.
- Regression file (
with_regression_file(path)): the runner appends each failure to a TOML file and re-runs every entry on startup before any random generation. Useful when you want past failures rerun on every CI build without changing the test source.
| You want to test | Reach for |
|---|---|
| "for every input the answer satisfies P" | metamon.forall |
| "transforming the input in this way preserves the output" | metamon.forall_morph with invariant_under or idempotency_of |
| "transforming the input in this way changes the output in this way" | metamon.forall_morph with equivariant_under or a hand-built MR |
| "this one specific input must always pass this MR" | metamon.assert_morph |
"all of these MRs must hold for the same f" |
metamon.forall_morphs |
forall_morphs requires ≥ 1 MR; passing [] panics with a structured
"empty MR list (vacuous test)" message — use forall(...) for the
no-MR case. Same applies to forall_morph_n and forall_morph_n_with
with an empty transforms list.
| Module | Responsibility |
|---|---|
metamon |
Top-level API: forall, forall_with, forall_observable, forall_observable_with, forall_morph, forall_morph_with, forall_morph_n, forall_morph_n_with, assert_morph, forall_morphs, forall_round_trip, forall_round_trip_with, forall_round_trip_partial, forall_round_trip_partial_with, forall_round_trip_under, forall_round_trip_under_with, Mr (opaque), mr, mr_equivariant, name_of, idempotency_of, invariant_under, equivariant_under, commutativity_of, OutputFormat, with_output_format, seed, random_seed, default_config and all with_* re-exports |
metamon/config |
Config, ConfigError, default_config, with_runs, with_seed, with_max_size, with_shrink_limit, with_max_edges, with_regression_file, with_diff_enabled |
metamon/generator |
Generator(a) (opaque), generate, sample, statistics, with_examples, add_edges, no_edges, return, map, bind, map2..map8, tuple2..tuple8, one_of, element_of, frequency, sized, resize, scale, filter, recursive, int, float, float_special, float_special_edges, bool, non_negative_int, positive_int, negative_int, byte, bit_array, bit_array_printable, bit_array_utf8, ascii_*, unicode_codepoint, string, string_ascii, string_alpha, string_alphanumeric, string_digit, string_printable_ascii, string_unicode, list_of, non_empty_list_of, dict_of, set_of, option_of, result_of |
metamon/generator/seed |
xorshift32-based Seed with split (target-portable; identical streams on BEAM and JS) |
metamon/generator/tree |
Lazy rose tree used as the integrated shrink representation |
metamon/generator/range |
singleton, constant, linear, linear_from, exponential (Hedgehog-style ranges) |
metamon/transform |
Transform(a), new, identity, constant, then, repeat, rename |
metamon/transform/list |
reverse, dedupe, prepend, append, shuffle |
metamon/transform/string |
reverse, lowercase, uppercase, trim, prepend, append |
metamon/transform/dict |
insert, remove, shuffle_keys |
metamon/relation |
Relation(b), new, equal, not_equal, equivalent_under, approximately, permutation_of, subset_of (multiset), set_subset_of (set), monotone, implies, and, or, invert, rename, RelationN(b), n_new, all_equal, pairwise (N-ary relations for forall_morph_n) |
metamon/diff |
Structural diff used in failure reports: diff, diff_string, render, Same/Differ/ListDiff/TupleDiff/StringDiff |
metamon/annotate |
annotate, annotate_value, footnote, reset, current_annotations, current_footnotes |
metamon/coverage |
classify, cover, cover_at_least, classify_in_bucket, collect, snapshot, shortfalls, actual_pct, target_pct_of, requirements_of, collected_of, hits_for, first_shortfall, Pct/Count requirement kinds |
metamon/command |
Command(model, real), new, no_precondition, always (alias of no_precondition), name_of (model-based testing primitive) |
metamon/stateful |
run(initial_model, initial_real, commands), assert_passed, Outcome (model-based test runner) |
- Gleam 1.15+
- BEAM target: Erlang/OTP 27 or later (CI covers OTP 27 and 28).
- JavaScript target: Node.js 22 or later (CI covers Node 22 and 24).
See doc/targets.md for the full target story, runtime dependency footprint, and behavioural differences between BEAM and JavaScript.
- doc/limitations.md: known scope cuts and
workarounds (
bindshrinking,recursivebranch-swap,filterretry limit, JS parallel runners, UTF-8 surrogate exclusion, float special-value asymmetry). - doc/targets.md: supported targets, runtime requirements, dependency footprint.
This project uses mise to manage Gleam and Erlang versions, and just as a task runner.
mise install # install Gleam and Erlang
just ci # download deps and run all checks
just test # gleam test
just format # gleam format
just check # all checks without deps downloadContributions are welcome. See CONTRIBUTING.md for details.