metamon

CIHex.pm

Property-based testing and metamorphic testing combinator library for Gleam.

metamon treats both styles of testing as first-class concepts:

The shape of these features is documented by How to use and the test suite under test/ — there is no separate design chapter here.

Requirements

Node.js 18 reached end-of-life in April 2025 and Node.js 20 reached end-of-life in April 2026. Node 22 is the current minimum.

Supported targets

Parallel test runners on JavaScript: the per-process state used by metamon/annotate and metamon/coverage is a module-level Map on the JavaScript target. If your test runner executes multiple metamon.forall* invocations in parallel within the same Node process (vitest workers, jest with --detectOpenHandles=false, etc.), annotation and coverage state can leak between properties. On the BEAM target every test runs in its own process, so the issue does not arise. Run JS tests sequentially within a process if you rely on these features.

Install

gleam add metamon --dev

Dependency footprint

metamon ships as a single package and has three runtime dependencies:

If you need a leaner test-only dep set, gleam_qcheck ships with gleam_stdlib alone and may be a better fit. metamon's design choice is the single-package install experience (gleam add metamon --dev and that's the whole story) over a multi-package split (metamon core / metamon_persistence / metamon_json); both options were considered. See #20 for the discussion.

Quick start

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"
  ...

How to use

The headings below correspond 1:1 to test functions in test/readme_test.gleam. Every snippet on this page is the body of a pub fn readme_*_test and is checked by gleam test on every CI run, so the examples cannot drift out of sync with the API.

1. Property-based testing — forall

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 },
  )
}

1.1. forall_observable — show the predicate's intermediate value

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.

2. Metamorphic relations

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.

2.1. Idempotency: f(f(x)) == f(x)

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,
  )
}

2.2. Round-trip: decode(encode(x)) == Ok(x)

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.

2.3. Invariance: f(T(x)) == f(x)

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,
  )
}

2.4. Equivariance: U(f(x)) == f(T(x))

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 }) },
  )
}

2.5. Manual MR construction

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 }) },
  )
}

2.6. assert_morph — single hand-supplied input

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)
}

2.7. Commutativity: op(a, b) == op(b, a)

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) },
  )
}

2.8. forall_morphs — multiple MRs against the same f

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,
  )
}

3. Generators

3.0. Shortcuts for the most common shapes

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 the most useful 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 already documented 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 so callers don't need generator.string(ascii_letter(), range.constant(1, 8)) boilerplate.

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.

3.1. Building record-shaped values with map2

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 })
}

map3 / map4 / map5 / map6 extend this to records of higher arity. tuple2tuple5 are shortcuts for the tupling case.

3.2. one_of, element_of, frequency

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.

3.3. with_examples — guarantee specific inputs are tried

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,
  )
}

3.4. Recursive generators

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
    }
  })
}

4. Transforms and relations

4.1. Composing transforms

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")
}

4.2. Combining relations

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: every element of the left list appears in the right.
  let sub = relation.subset_of()
  should.be_true(sub.holds([2, 3], [1, 2, 3, 4]))
}

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))
}

4.3. equivalent_under — relation on a normalised view

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"))
}

5. Coverage and annotations

5.1. cover and classify

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)
    },
  )
}

5.2. annotate and footnote

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
    },
  )
}

5.3. JSON output for CI / LLM consumers

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 are 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.

5.4. N-ary metamorphic relations (forall_morph_n)

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.

5.5. Stateful / model-based testing

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.always skips the precondition; use command.new to gate commands on the current model. stateful.assert_passed panics with a structured failure message when a command's run returns Error.

6. Configuration

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.

Reading a failure report

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:

Limitations

These are deliberate scope cuts, not bugs. They are listed so you know how to work around them.

Modules

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, 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/configConfig, ConfigError, default_config, with_runs, with_seed, with_max_size, with_shrink_limit, with_max_edges, with_regression_file, with_diff_enabled
metamon/generatorGenerator(a) (opaque), generate, sample, statistics, with_examples, add_edges, no_edges, return, map, bind, map2..map6, tuple2..tuple5, one_of, element_of, frequency, sized, resize, scale, filter, recursive, int, float, 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/rangesingleton, constant, linear, linear_from, exponential (Hedgehog-style ranges)
metamon/transformTransform(a), new, identity, constant, then, repeat, rename
metamon/transform/listreverse, dedupe, prepend, append, shuffle
metamon/transform/stringreverse, lowercase, uppercase, trim, prepend, append
metamon/transform/dictinsert, remove, shuffle_keys
metamon/relationRelation(b), new, equal, not_equal, equivalent_under, approximately, permutation_of, subset_of, 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/annotateannotate, annotate_value, footnote, reset, current_annotations, current_footnotes
metamon/coverageclassify, 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/commandCommand(model, real), new, always, name_of (model-based testing primitive)
metamon/statefulrun(initial_model, initial_real, commands), assert_passed, Outcome (model-based test runner)

Choosing PBT vs MT vs assert_morph

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

Development

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 download

Contributing

Contributions are welcome. See CONTRIBUTING.md for details.

License

MIT